# Copyright (c) 2003-2005 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh@intevation.de> (2003, 2004)
# Jonathan Coles <jonathan@intevation.de> (2003)
# Jan-Oliver Wagner <jan@intevation.de> (2004)
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with Thuban for details.

"""
Classes for display of a map and interaction with it
"""

__version__ = "$Revision: 2885 $"
# $Source$
# $Id: viewport.py 2885 2009-08-18 13:35:30Z dpinte $

import sys
from math import hypot

from wxproj import shape_centroid

from Thuban.Model.messages import MAP_PROJECTION_CHANGED, \
     LAYER_PROJECTION_CHANGED, TITLE_CHANGED, \
     MAP_LAYERS_ADDED, MAP_LAYERS_REMOVED
from Thuban.Model.data import SHAPETYPE_POLYGON, SHAPETYPE_ARC, \
     SHAPETYPE_POINT, RAW_SHAPEFILE
from Thuban.Model.label import ALIGN_CENTER, ALIGN_TOP, ALIGN_BOTTOM, \
     ALIGN_LEFT, ALIGN_RIGHT
from Thuban.Lib.connector import Publisher, Conduit
from Thuban.Model.color import Transparent

from selection import Selection

from messages import LAYER_SELECTED, SHAPES_SELECTED, VIEW_POSITION, \
     SCALE_CHANGED, MAP_REPLACED

import hittest

#
#   The tools
#

class Tool:

    """
    Base class for the interactive tools
    """

    def __init__(self, view):
        """Intitialize the tool. The view is the canvas displaying the map"""
        self.view = view
        self.start = self.current = None
        self.dragging = 0
        self.drawn = 0

    def __del__(self):
        del self.view

    def Name(self):
        """Return the tool's name"""
        return ''

    def drag_start(self, x, y):
        self.start = self.current = x, y
        self.dragging = 1

    def drag_move(self, x, y):
        self.current = x, y

    def drag_stop(self, x, y):
        self.current = x, y
        self.dragging = 0

    def Show(self, dc):
        if not self.drawn:
            self.draw(dc)
        self.drawn = 1

    def Hide(self, dc):
        if self.drawn:
            self.draw(dc)
        self.drawn = 0

    def draw(self, dc):
        pass

    def MouseDown(self, event):
        self.drag_start(event.m_x, event.m_y)

    def MouseMove(self, event):
        if self.dragging:
            self.drag_move(event.m_x, event.m_y)

    def MouseUp(self, event):
        if self.dragging:
            self.drag_stop(event.m_x, event.m_y)
    
    def MouseDoubleClick(self, event):
        pass

    def Cancel(self):
        self.dragging = 0


class RectTool(Tool):

    """Base class for tools that draw rectangles while dragging"""

    def draw(self, dc):
        sx, sy = self.start
        cx, cy = self.current
        dc.DrawRectangle(sx, sy, cx - sx, cy - sy)

class ZoomInTool(RectTool):

    """The Zoom-In Tool"""

    def Name(self):
        return "ZoomInTool"

    def proj_rect(self):
        """return the rectangle given by start and current in projected
        coordinates"""
        sx, sy = self.start
        cx, cy = self.current
        left, top = self.view.win_to_proj(sx, sy)
        right, bottom = self.view.win_to_proj(cx, cy)
        return (min(left, right), min(top, bottom),
                max(left, right), max(top, bottom))

    def MouseUp(self, event):
        if self.dragging:
            Tool.MouseUp(self, event)
            sx, sy = self.start
            cx, cy = self.current
            if sx == cx or sy == cy:
                # Just a mouse click or a degenerate rectangle. Simply
                # zoom in by a factor of two
                # FIXME: For a click this is the desired behavior but should we
                # really do this for degenrate rectagles as well or
                # should we ignore them?
                self.view.ZoomFactor(2, center = (cx, cy))
            else:
                # A drag. Zoom in to the rectangle
                self.view.FitRectToWindow(self.proj_rect())


class ZoomOutTool(RectTool):

    """The Zoom-Out Tool"""

    def Name(self):
        return "ZoomOutTool"

    def MouseUp(self, event):
        if self.dragging:
            Tool.MouseUp(self, event)
            sx, sy = self.start
            cx, cy = self.current
            if sx == cx or sy == cy:
                # Just a mouse click or a degenerate rectangle. Simply
                # zoom out by a factor of two.
                # FIXME: For a click this is the desired behavior but should we
                # really do this for degenrate rectagles as well or
                # should we ignore them?
                self.view.ZoomFactor(0.5, center = (cx, cy))
            else:
                # A drag. Zoom out to the rectangle
                self.view.ZoomOutToRect((min(sx, cx), min(sy, cy),
                                         max(sx, cx), max(sy, cy)))

class PanTool(Tool):

    """The Pan Tool"""

    def Name(self):
        return "PanTool"

    def MouseMove(self, event):
        if self.dragging:
            Tool.MouseMove(self, event)

    def MouseUp(self, event):
        if self.dragging:
            Tool.MouseUp(self, event)
            sx, sy = self.start
            cx, cy = self.current
            self.view.Translate(cx - sx, cy - sy)

class IdentifyTool(Tool):

    """The "Identify" Tool"""

    def Name(self):
        return "IdentifyTool"

    def MouseUp(self, event):
        self.view.SelectShapeAt(event.m_x, event.m_y)


class LabelTool(Tool):

    """The "Label" Tool"""

    def Name(self):
        return "LabelTool"

    def MouseUp(self, event):
        self.view.LabelShapeAt(event.m_x, event.m_y)


class ViewPort(Conduit):

    """An abstract view of the main window"""

    # Some messages that can be subscribed/unsubscribed directly through
    # the MapCanvas come in fact from other objects. This is a dict
    # mapping those messages to the names of the instance variables they
    # actually come from. The delegation is implemented in the Subscribe
    # and Unsubscribe methods
    delegated_messages = {LAYER_SELECTED: "selection",
                          SHAPES_SELECTED: "selection"}

    # Methods delegated to some instance variables. The delegation is
    # implemented in the __getattr__ method.
    delegated_methods = {"SelectLayer": "selection",
                         "SelectShapes": "selection",
                         "SelectedLayer": "selection",
                         "HasSelectedLayer": "selection",
                         "HasSelectedShapes": "selection",
                         "SelectedShapes": "selection"}

    # Some messages are forwarded from the currently shown map.  This is
    # simply a list of the channels to forward.  The _subscribe_map and
    # _unsubscribe_map methods use this to handle the forwarding.
    forwarded_map_messages = (LAYER_PROJECTION_CHANGED, TITLE_CHANGED,
                              MAP_PROJECTION_CHANGED,
                              MAP_LAYERS_ADDED, MAP_LAYERS_REMOVED)

    def __init__(self, size = (400, 300)):

        self.size = size

        # the map displayed in this canvas. Set with SetMap()
        self.map = None

        # scale and offset describe the transformation from projected
        # coordinates to window coordinates.
        self.scale = 1.0
        self.offset = (0, 0)

        # whether the user is currently dragging the mouse, i.e. moving
        # the mouse while pressing a mouse button
        self.dragging = 0

        # the currently active tool
        self.tool = None

        # The current mouse position of the last OnMotion event or None
        # if the mouse is outside the window.
        self.current_position = None

        # the selection
        self.selection = Selection()
        self.selection.Subscribe(SHAPES_SELECTED, self.shape_selected)

        # keep track of which layers/shapes are selected to make sure we
        # only redraw when necessary
        self.last_selected_layer = None
        self.last_selected_shape = None

    def Destroy(self):
        self._unsubscribe_map(self.map)
        self.map = None
        self.selection.Destroy()
        self.tool = None

    def Subscribe(self, channel, *args):
        """Extend the inherited method to handle delegated messages.

        If channel is one of the delegated messages call the appropriate
        object's Subscribe method. Otherwise just call the inherited
        method.
        """
        if channel in self.delegated_messages:
            object = getattr(self, self.delegated_messages[channel])
            object.Subscribe(channel, *args)
        else:
            Conduit.Subscribe(self, channel, *args)

    def Unsubscribe(self, channel, *args):
        """Extend the inherited method to handle delegated messages.

        If channel is one of the delegated messages call the appropriate
        object's Unsubscribe method. Otherwise just call the inherited
        method.
        """
        if channel in self.delegated_messages:
            object = getattr(self, self.delegated_messages[channel])
            object.Unsubscribe(channel, *args)
        else:
            Conduit.Unsubscribe(self, channel, *args)

    def __getattr__(self, attr):
        if attr in self.delegated_methods:
            return getattr(getattr(self, self.delegated_methods[attr]), attr)
        raise AttributeError(attr)

    def SetMap(self, map):
        self._unsubscribe_map(self.map)
        changed = self.map is not map
        self.map = map
        self.selection.ClearSelection()
        self._subscribe_map(self.map)
        self.FitMapToWindow()
        self.issue(MAP_REPLACED)

    def _subscribe_map(self, map):
        """Internal: Subscribe to some of the map's messages"""
        if map is not None:
            map.Subscribe(LAYER_PROJECTION_CHANGED,
                          self.layer_projection_changed)
            map.Subscribe(MAP_PROJECTION_CHANGED,
                          self.map_projection_changed)
            for channel in self.forwarded_map_messages:
                self.subscribe_forwarding(channel, map)

    def _unsubscribe_map(self, map):
        """
        Internal: Unsubscribe from the messages subscribed to in _subscribe_map
        """
        if map is not None:
            for channel in self.forwarded_map_messages:
                self.unsubscribe_forwarding(channel, map)
            map.Unsubscribe(MAP_PROJECTION_CHANGED,
                            self.map_projection_changed)
            map.Unsubscribe(LAYER_PROJECTION_CHANGED,
                            self.layer_projection_changed)

    def Map(self):
        """Return the map displayed by this canvas or None if no map is shown
        """
        return self.map

    def map_projection_changed(self, map, old_proj):
        """Subscribed to the map's MAP_PROJECTION_CHANGED message

        If the projection changes, the region shown is probably not
        meaningful anymore in the new projection.  Therefore this method
        tries to keep the same region visible as before.
        """
        proj = self.map.GetProjection()

        bbox = None

        if old_proj is not None and proj is not None and self.map.HasLayers():
            width, height = self.GetPortSizeTuple()
            llx, lly = self.win_to_proj(0, height)
            urx, ury = self.win_to_proj(width, 0)
            bbox = old_proj.InverseBBox((llx, lly, urx, ury))
            bbox = proj.ForwardBBox(bbox)

        if bbox is not None:
            self.FitRectToWindow(bbox)
        else:
            self.FitMapToWindow()

    def layer_projection_changed(self, *args):
        """Subscribed to the LAYER_PROJECTION_CHANGED messages

        This base-class implementation does nothing currently, but it
        can be extended in derived classes to e.g. redraw the window.
        """

    def calc_min_max_scales(self, scale = None):
        if scale is None:
            scale = self.scale

        llx, lly, urx, ury = bbox = self.map.ProjectedBoundingBox()
        pwidth = float(urx - llx)
        pheight = float(ury - lly)

        # width/height of the window
        wwidth, wheight = self.GetPortSizeTuple()

        # The window coordinates used when drawing the shapes must fit
        # into 16bit signed integers.
        max_len = max(pwidth, pheight)
        if max_len:
            max_scale = sys.maxint #.0  #/ max_len
        else:
            # FIXME: What to do in this case? The bbox is effectively
            # empty so any scale should work.
            max_scale = scale

        # The minimal scale is somewhat arbitrarily set to half that of
        # the bbox fit into the window
        scales = []
        if pwidth:
            scales.append(wwidth / pwidth)
        if pheight:
            scales.append(wheight / pheight)
        if scales:
            min_scale = 0.0001 * min(scales)
        else:
            min_scale = scale

        return min_scale, max_scale

    def set_view_transform(self, scale, offset):
        # width/height of the window
        wwidth, wheight = self.GetPortSizeTuple()

        # The window's center in projected coordinates assuming the new
        # scale/offset
        try:
            pcenterx = (wwidth/2 - offset[0]) / scale
            pcentery = (offset[1] - wheight/2) / scale
        except ZeroDivisionError:
            # scale was zero.  Probably a problem with the projections.
            # printing an error message and return immediately. The user
            # will hopefully be informed by the UI which displays a
            # message for at least some of the projection problems.
            print >>sys.stderr, "ViewPort.set_view_transform:", \
                  "ZeroDivisionError, scale =", repr(scale)
            return

        min_scale, max_scale = self.calc_min_max_scales(scale)

        if scale > max_scale:
            scale = max_scale
        elif scale < min_scale:
            scale = min_scale

        self.scale = scale
        if self.scale == 0:
            raise Exception("Scale exception : %s %s") 

        # determine new offset to preserve the center
        self.offset = (wwidth/2 - scale * pcenterx,
                       wheight/2 + scale * pcentery)
        self.issue(SCALE_CHANGED, scale)

    def GetPortSizeTuple(self):
        return self.size

    def proj_to_win(self, x, y):
        """\
        Return the point in  window coords given by projected coordinates x y
        """
        offx, offy = self.offset
        return (self.scale * x + offx, -self.scale * y + offy)

    def win_to_proj(self, x, y):
        """\
        Return the point in projected coordinates given by window coords x y
        """
        offx, offy = self.offset
        return ((x - offx) / self.scale, (offy - y) / self.scale)

    def VisibleExtent(self):
        """Return the extent of the visible region in projected coordinates

        The return values is a tuple (llx, lly, urx, ury) describing the
        region.
        """
        width, height = self.GetPortSizeTuple()
        llx, lly = self.win_to_proj(0, height)
        urx, ury = self.win_to_proj(width, 0)
        return (llx, lly, urx, ury)

    def FitRectToWindow(self, rect):
        """Fit the rectangular region given by rect into the window.

        Set scale so that rect (in projected coordinates) just fits into
        the window and center it.
        """
        width, height = self.GetPortSizeTuple()
        llx, lly, urx, ury = rect
        if llx == urx or lly == ury:
            # zero width or zero height. Do Nothing
            return
        scalex = width / (urx - llx)
        scaley = height / (ury - lly)
        scale = min(scalex, scaley)
        offx = 0.5 * (width - (urx + llx) * scale)
        offy = 0.5 * (height + (ury + lly) * scale)
        self.set_view_transform(scale, (offx, offy))

    def FitMapToWindow(self):
        """Fit the map to the window

        Set the scale so that the map fits exactly into the window and
        center it in the window.
        """
        if self.map is not None:
            bbox = self.map.ProjectedBoundingBox()
            if bbox is not None:
                self.FitRectToWindow(bbox)

    def FitLayerToWindow(self, layer):
        """Fit the given layer to the window.

        Set the scale so that the layer fits exactly into the window and
        center it in the window.
        """

        bbox = layer.LatLongBoundingBox()
        if bbox is not None:
            proj = self.map.GetProjection()
            if proj is not None:
                bbox = proj.ForwardBBox(bbox)

            if bbox is not None:
                self.FitRectToWindow(bbox)

    def FitSelectedToWindow(self):
        layer = self.selection.SelectedLayer()
        shapes = self.selection.SelectedShapes()

        bbox = layer.ShapesBoundingBox(shapes)
        if bbox is not None:
            proj = self.map.GetProjection()
            if proj is not None:
                bbox = proj.ForwardBBox(bbox)

            if bbox is not None:
                if len(shapes) == 1 and layer.ShapeType() == SHAPETYPE_POINT:
                    self.ZoomFactor(self.calc_min_max_scales()[1] / self.scale,
                                    self.proj_to_win(bbox[0], bbox[1]))
                else:
                    self.FitRectToWindow(bbox)

    def ZoomFactor(self, factor, center = None):
        """Multiply the zoom by factor and center on center.

        The optional parameter center is a point in window coordinates
        that should be centered. If it is omitted, it defaults to the
        center of the window
        """
        width, height = self.GetPortSizeTuple()
        scale = self.scale * factor
        offx, offy = self.offset
        if center is not None:
            cx, cy = center
        else:
            cx = width / 2
            cy = height / 2
        offset = (factor * (offx - cx) + width  / 2,
                  factor * (offy - cy) + height / 2)
        self.set_view_transform(scale, offset)

    def ZoomOutToRect(self, rect):
        """Zoom out to fit the currently visible region into rect.

        The rect parameter is given in window coordinates
        """
        # determine the bbox of the displayed region in projected
        # coordinates
        width, height = self.GetPortSizeTuple()
        llx, lly = self.win_to_proj(0, height - 1)
        urx, ury = self.win_to_proj(width - 1, 0)

        sx, sy, ex, ey = rect
        scalex = (ex - sx) / (urx - llx)
        scaley = (ey - sy) / (ury - lly)
        scale = min(scalex, scaley)

        offx = 0.5 * ((ex + sx) - (urx + llx) * scale)
        offy = 0.5 * ((ey + sy) + (ury + lly) * scale)
        self.set_view_transform(scale, (offx, offy))

    def Translate(self, dx, dy):
        """Move the map by dx, dy pixels"""
        offx, offy = self.offset
        self.set_view_transform(self.scale, (offx + dx, offy + dy))

    def SelectTool(self, tool):
        """Make tool the active tool.

        The parameter should be an instance of Tool or None to indicate
        that no tool is active.
        """
        self.tool = tool

    def ZoomInTool(self):
        """Start the zoom in tool"""
        self.SelectTool(ZoomInTool(self))

    def ZoomOutTool(self):
        """Start the zoom out tool"""
        self.SelectTool(ZoomOutTool(self))

    def PanTool(self):
        """Start the pan tool"""
        self.SelectTool(PanTool(self))

    def IdentifyTool(self):
        """Start the identify tool"""
        self.SelectTool(IdentifyTool(self))

    def LabelTool(self):
        """Start the label tool"""
        self.SelectTool(LabelTool(self))

    def CurrentTool(self):
        """Return the name of the current tool or None if no tool is active"""
        return self.tool and self.tool.Name() or None

    def CurrentPosition(self):
        """Return current position of the mouse in projected coordinates.

        The result is a 2-tuple of floats with the coordinates. If the
        mouse is not in the window, the result is None.
        """
        if self.current_position is not None:
            x, y = self.current_position
            return self.win_to_proj(x, y)
        else:
            return None

    def set_current_position(self, event):
        """Set the current position from event

        Should be called by all events that contain mouse positions
        especially EVT_MOTION. The event parameter may be None to
        indicate the the pointer left the window.
        """
        if event is not None:
            self.current_position = (event.m_x, event.m_y)
        else:
            self.current_position = None
        self.issue(VIEW_POSITION)

    def MouseLeftDown(self, event):
        self.set_current_position(event)
        if self.tool is not None:
            self.tool.MouseDown(event)

    def MouseLeftUp(self, event):
        self.set_current_position(event)
        if self.tool is not None:
            self.tool.MouseUp(event)

    def MouseMove(self, event):
        self.set_current_position(event)
        if self.tool is not None:
            self.tool.MouseMove(event)
    
    def MouseDoubleClick(self, event):
        if self.tool is not None:
            self.tool.MouseDoubleClick(event)

    def shape_selected(self, layer, shape):
        """Receiver for the SHAPES_SELECTED messages. Redraw the map."""
        # The selection object takes care that it only issues
        # SHAPES_SELECTED messages when the set of selected shapes has
        # actually changed, so we can do a full redraw unconditionally.
        # FIXME: We should perhaps try to limit the redraw to the are
        # actually covered by the shapes before and after the selection
        # change.
        pass

    def unprojected_rect_around_point(self, x, y, dist):
        """Return a rect dist pixels around (x, y) in unprojected coordinates

        The return value is a tuple (minx, miny, maxx, maxy) suitable a
        parameter to a layer's ShapesInRegion method.
        """
        map_proj = self.map.projection
        if map_proj is not None:
            inverse = map_proj.Inverse
        else:
            inverse = None

        xs = []
        ys = []
        for dx, dy in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
            px, py = self.win_to_proj(x + dist * dx, y + dist * dy)
            if inverse:
                px, py = inverse(px, py)
            xs.append(px)
            ys.append(py)
        return (min(xs), min(ys), max(xs), max(ys))

    def GetTextExtent(self, text):
        """Return the extent of the text

        This method must be implemented by derived classes. The return
        value must have the same format as that of the GetTextExtent of
        the wx DC objects.
        """
        raise NotImplementedError

    def find_shape_at(self, px, py, select_labels = 0, searched_layer = None):
        """Determine the shape at point px, py in window coords

        Return the shape and the corresponding layer as a tuple (layer,
        shapeid).

        If the optional parameter select_labels is true (default false)
        search through the labels. If a label is found return it's index
        as the shape and None as the layer.

        If the optional parameter searched_layer is given (or not None
        which it defaults to), only search in that layer.
        """

        # First if the caller wants to select labels, search and return
        # it if one is found. We must do this first because the labels
        # are currently always drawn above all other layers.
        if select_labels:
            label = self._find_label_at(px, py)
            if label is not None:
                return None, label

        #
        # Search the normal layers
        #

        # Determine which layers to search. If the caller gave a
        # specific layer, we only search that. Otherwise we have to
        # search all visible vector layers in the map in reverse order.
        if searched_layer:
            layers = [searched_layer]
        else:
            layers = [layer for layer in self.map.Layers()
                            if layer.HasShapes() and layer.Visible()]
            layers.reverse()

        # Search through the layers.
        for layer in layers:
            shape = self._find_shape_in_layer(layer, px, py)
            if shape is not None:
                return layer, shape
        return None, None

    def _find_shape_in_layer(self, layer, px, py):
        """Internal: Return the id of the shape at (px, py) in layer

        Return None if no shape is at those coordinates.
        """

        # For convenience, bind some methods and values to local
        # variables.
        map_proj = self.map.projection
        if map_proj is not None:
            forward = map_proj.Forward
        else:
            forward = None

        scale = self.scale

        offx, offy = self.offset

        table = layer.ShapeStore().Table()
        lc = layer.GetClassification()
        field = layer.GetClassificationColumn()

        # defaults to fall back on
        filled = lc.GetDefaultFill() is not Transparent
        stroked = lc.GetDefaultLineColor() is not Transparent

        # Determine the ids of the shapes that overlap a tiny area
        # around the point. For layers containing points we have to
        # choose a larger size of the box we're testing against so
        # that we take the size of the markers into account
        # The size of the box is determined by the largest symbol
        # of the corresponding layer.
        maxsize = 1
        if layer.ShapeType() == SHAPETYPE_POINT:
            for group in layer.GetClassification():
                props = group.GetProperties()
                if props.GetSize() > maxsize:
                    maxsize = props.GetSize()
        box = self.unprojected_rect_around_point(px, py, maxsize)

        # determine the function that does the hit test based on the layer
        hittester = self._get_hit_tester(layer)

        for shape in layer.ShapesInRegion(box):
            if field:
                record = table.ReadRowAsDict(shape.ShapeID())
                group = lc.FindGroup(record[field])
                props = group.GetProperties()
                filled = props.GetFill() is not Transparent
                stroked = props.GetLineColor() is not Transparent

            if layer.ShapeType() == SHAPETYPE_POINT:
                hit = hittester(layer, shape, filled, stroked,
                                px, py, size = props.GetSize())
            else:
                hit = hittester(layer, shape, filled, stroked, px, py)

            if hit:
                return shape.ShapeID()
        return None

    def _get_hit_tester(self, layer):
        """Internal: Return a hit tester suitable for the layer

        The return value is a callable that accepts a shape object and
        some other parameters and and returns a boolean to indicate
        whether that shape has been hit. The callable is called like
        this:

           callable(layer, shape, filled, stroked, x, y)
        """
        store = layer.ShapeStore()
        shapetype = store.ShapeType()

        if shapetype == SHAPETYPE_POINT:
            return self._hit_point
        elif shapetype == SHAPETYPE_ARC:
            return self._hit_arc
        elif shapetype == SHAPETYPE_POLYGON:
            return self._hit_polygon
        else:
            raise ValueError("Unknown shapetype %r" % shapetype)

    def projected_points(self, layer, points):
        """Return the projected coordinates of the points taken from layer.

        Transform all the points in the list of lists of coordinate
        pairs in points.

        The transformation applies the inverse of the layer's projection
        if any, then the map's projection if any and finally applies
        self.scale and self.offset.

        The returned list has the same structure as the one returned the
        shape's Points method.
        """
        proj = self.map.GetProjection()
        if proj is not None:
            forward = proj.Forward
        else:
            forward = None
        proj = layer.GetProjection()
        if proj is not None:
            inverse = proj.Inverse
        else:
            inverse = None
        result = []
        scale = self.scale
        offx, offy = self.offset
        for part in points:
            result.append([])
            for x, y in part:
                if inverse:
                    x, y = inverse(x, y)
                if forward:
                    x, y = forward(x, y)
                result[-1].append((x * scale + offx,
                                   -y * scale + offy))
        return result

    def _hit_point(self, layer, shape, filled, stroked, px, py, size = 5):
        """Internal: return whether a click at (px,py) hits the point shape

        The filled and stroked parameters determine whether the shape is
        assumed to be filled or stroked respectively.

        size -- defines the size of the point symbol. For the hitting
                test it is assumed the symbol is a circle of this size
                (radius).
        """
        x, y = self.projected_points(layer, shape.Points())[0][0]
        return hypot(px - x, py - y) < size and (filled or stroked)

    def _hit_arc(self, layer, shape, filled, stroked, px, py):
        """Internal: return whether a click at (px,py) hits the arc shape

        The filled and stroked parameters determine whether the shape is
        assumed to be filled or stroked respectively.
        """
        if not stroked:
            return 0
        points = self.projected_points(layer, shape.Points())
        return hittest.arc_hit(points, px, py)

    def _hit_polygon(self, layer, shape, filled, stroked, px, py):
        """Internal: return whether a click at (px,py) hits the polygon shape

        The filled and stroked parameters determine whether the shape is
        assumed to be filled or stroked respectively.
        """
        points = self.projected_points(layer, shape.Points())
        hit = hittest.polygon_hit(points, px, py)
        if filled:
            return bool(hit)
        return stroked and hit < 0

    def _find_label_at(self, px, py):
        """Internal: Find the label at (px, py) and return its index

        Return None if no label is hit.
        """
        map_proj = self.map.projection
        if map_proj is not None:
            forward = map_proj.Forward
        else:
            forward = None
        scale = self.scale
        offx, offy = self.offset

        labels = self.map.LabelLayer().Labels()
        if labels:
            for i in range(len(labels) - 1, -1, -1):
                label = labels[i]
                x = label.x
                y = label.y
                text = label.text
                if forward:
                    x, y = forward(x, y)
                x = x * scale + offx
                y = -y * scale + offy
                width, height = self.GetTextExtent(text)
                if label.halign == ALIGN_LEFT:
                    # nothing to be done
                    pass
                elif label.halign == ALIGN_RIGHT:
                    x = x - width
                elif label.halign == ALIGN_CENTER:
                    x = x - width/2
                if label.valign == ALIGN_TOP:
                    # nothing to be done
                    pass
                elif label.valign == ALIGN_BOTTOM:
                    y = y - height
                elif label.valign == ALIGN_CENTER:
                    y = y - height/2
                if x <= px < x + width and y <= py <= y + height:
                    return i
        return None

    def SelectShapeAt(self, x, y, layer = None):
        """\
        Select and return the shape and its layer at window position (x, y)

        If layer is given, only search in that layer. If no layer is
        given, search through all layers.

        Return a tuple (layer, shapeid). If no shape is found, return
        (None, None).
        """
        layer, shape = result = self.find_shape_at(x, y, searched_layer=layer)
        # If layer is None, then shape will also be None. We don't want
        # to deselect the currently selected layer, so we simply select
        # the already selected layer again.
        if layer is None:
            layer = self.selection.SelectedLayer()
            shapes = []
        else:
            shapes = [shape]
        self.selection.SelectShapes(layer, shapes)
        return result

    def LabelShapeAt(self, x, y, text = None):
        """Add or remove a label at window position x, y.

        If there's a label at the given position, remove it. Otherwise
        determine the shape at the position and add a label.

        Return True is an action was performed, False otherwise.
        """
        label_layer = self.map.LabelLayer()
        layer, shape_index = self.find_shape_at(x, y, select_labels = 1)
        if layer is None and shape_index is not None:
            # a label was selected
            label_layer.RemoveLabel(shape_index)
            return True
        elif layer is not None and text:
            (x, y, halign, valign)  = layer.GetLabelPosFromShape(self.map, \
                                                             shape_index)
            label_layer.AddLabel(x, y, text,
                                 halign = halign, valign = valign)
            return True
        return False

def output_transform(canvas_scale, canvas_offset, canvas_size, device_extend):
    """Calculate dimensions to transform canvas content to output device."""
    width, height = device_extend

    # Only 80 % of the with are available for the map
    width = width * 0.8

    # Define the distance of the map from DC border
    distance = 20

    if height < width:
        # landscape
        map_height = height - 2*distance
        map_width = map_height
    else:
        # portrait, recalibrate width (usually the legend width is too
        # small
        width = width * 0.9
        map_height = width - 2*distance
        map_width = map_height

    mapregion = (distance, distance,
                 distance+map_width, distance+map_height)

    canvas_width, canvas_height = canvas_size

    scalex = map_width / (canvas_width/canvas_scale)
    scaley = map_height / (canvas_height/canvas_scale)
    scale = min(scalex, scaley)
    canvas_offx, canvas_offy = canvas_offset
    offx = scale*canvas_offx/canvas_scale
    offy = scale*canvas_offy/canvas_scale

    return scale, (offx, offy), mapregion
