import java.awt.LayoutManager;
import java.awt.BorderLayout;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelListener;
import java.awt.event.MouseWheelEvent;
import java.awt.Graphics;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import javax.swing.event.MouseInputAdapter;

import org.geotools.map.DefaultMapContext;
import org.geotools.map.MapContext;
import org.geotools.renderer.GTRenderer;
import org.geotools.renderer.lite.StreamingRenderer;
import org.geotools.gui.swing.MouseSelectionTracker_Public;
import org.geotools.gui.swing.event.GeoMouseEvent;

import com.vividsolutions.jts.geom.Envelope;
import com.vividsolutions.jts.geom.Coordinate;


/**
 * Diese Klasse erweitert die Geotools-Klasse {@link org.geotools.gui.swing.JMapPane} um
 * zusaetzliche Maus-Steuerungen:
 * <ul>
 *   <li><b>Linksklick:</b> Zoom-In (Faktor 2)</li>
 *   <li><b>Rechtsklick:</b> Zoom-Out (Faktor 2)</li>
 *   <li><b>Drag mit linker Maustaste:</b> neuen Karten-Bereich selektieren</li>
 *   <li><b>Drag mit rechter Maustaste:</b> Karten-Bereich verschieben</li>
 *   <li><b>Mausrad:</b> Zoom-In/Out ueber aktueller Position (Faktor 1.2)</li>
 * </ul>
 * Darueberhinaus besteht ueber {@link #getTransform()} Zugriff auf eine
 * {@linkplain AffineTransform affine Transformation} mit der die aktuellen
 * Fenster-Koordinaten (z.B. eines <code>MouseEvent</code>) in Karten-Koordinaten
 * (Latitude/Longitude) umgerechnet werden koennen.
 * @author <a href="mailto:schmitzm@bonn.edu">Martin Schmitz</a> (University of Bonn/Germany)
 * @version 1.0
 */
public class JMapPane extends org.geotools.gui.swing.JMapPane {
  /** Transformation zwischen Fenster-Koordinaten und Karten-Koordinaten (lat/lon) */
  protected AffineTransform transform = null;

  /**
   * Erzeugt ein neues MapPane mit {@link BorderLayout}, {@link StreamingRenderer} und
   * {@link DefaultMapContext}.
   */
  public JMapPane() {
    this( new BorderLayout(),
          true,
          new StreamingRenderer(),
          new DefaultMapContext()
    );
  }

  /**
   * Erzeugt ein neues MapPane.
   * @param layout Layout-Manager fuer die GUI-Komponente (z.B. {@link BorderLayout})
   * @param isDoubleBuffered siehe Konstruktor der {@linkplain org.geotools.gui.swing.JMapPane#JMapPane(LayoutManager,boolean,GTRenderer,MapContext) Oberklasse}
   * @param renderer Renderer fuer die graphische Darestellung (z.B. {@link StreamingRenderer})
   * @param context  Verwaltung der einzelnen Layer (z.B. {@link DefaultMapContext}).
   */
  public JMapPane(LayoutManager layout, boolean isDoubleBuffered, GTRenderer renderer, MapContext context) {
    super(layout,isDoubleBuffered,renderer,context);

    // Listener, der auf eine Karten-Auswahl via "Drag" lauscht und mit
    // Zoom reagiert
    MouseSelectionTracker_Public selTracker = new MouseSelectionTracker_Public() {
      protected void selectionPerformed(int ox, int oy, int px, int py) {
        performSelectionEvent(ox,oy,px,py);
      }
    };
    this.addMouseListener( selTracker );

    // Listener, der auf das Mausrad lauscht und mit Zoom reagiert
    this.addMouseWheelListener( new MouseWheelListener() {
      public void mouseWheelMoved(MouseWheelEvent e) {
        performMouseWheelEvent(e);
      }
    });

    // Listener, der auf die rechte Maustaste lauscht und Kartenbewegung reagiert
    MouseInputAdapter mia = new MouseInputAdapter() {
      private Point2D  pressedPos = null;
      private Envelope pressedEnv = null;

      public void mousePressed(MouseEvent e) {
        // Start-Position merken
        if ( e.getButton() == e.BUTTON3 ) {
          pressedPos = e.getPoint();
          pressedEnv = getMapArea();
        }
      }
      public void mouseDragged(MouseEvent e) {
        if ( pressedPos != null )
          performMouseDraggedEvent(pressedPos, pressedEnv, e);
      }
      public void mouseReleased(MouseEvent e) {
        // Start-Position zuruecksetzen
        if ( e.getButton() == e.BUTTON3 ) {
          pressedPos = null;
          pressedEnv = null;
        }
      }
    };
    this.addMouseListener(mia);
    this.addMouseMotionListener(mia);
  }

  /**
   * Konvertiert die Maus-Koordinaten (relativ zum <code>JMapPane</code>) in
   * Karten-Koordinaten.
   * @param e Maus-Ereignis
   */
  public static Point2D getMapCoordinatesFromEvent(MouseEvent e) {
    // aktuelle Geo-Position aus GeoMouseEvent ermitteln
    if ( e != null && e instanceof GeoMouseEvent )
      try {
        return ( (GeoMouseEvent) e).getMapCoordinate(null).toPoint2D();
      } catch (Exception err) {
        err.printStackTrace();
      }

    // aktuelle Geo-Position ueber Transformation des JMapPane berechnen
    if ( e != null && e.getSource() instanceof JMapPane ) {
      AffineTransform at = ((JMapPane)e.getSource()).getTransform();
      if ( at != null )
       return at.transform( e.getPoint(), null );
    }

    return null;
  }

  /**
   * Verarbeitet die Selektion eines Karten-Ausschnitts.
   * @param ox X-Koordinate der VON-Position
   * @param oy Y-Koordinate der VON-Position
   * @param px X-Koordinate der BIS-Position
   * @param py Y-Koordinate der BIS-Position
   */
  protected void performSelectionEvent(int ox, int oy, int px, int py) {
    if ( getContext().getLayerCount() == 0 )
      return;

    // keine wirkliche Selektion, sondern nur ein Klick
    if (ox == px || oy == py)
      return;
    // Fenster-Koordinaten in Map-Koordinaten umwandeln
    AffineTransform at = getTransform();
    Point2D geoO = at.transform(new Point2D.Double(ox, oy), null);
    Point2D geoP = at.transform(new Point2D.Double(px, py), null);

    // Karte neu setzen
    this.setMapArea(new Envelope(geoO.getX(), geoP.getX(), geoO.getY(), geoP.getY()));
    setReset(true); // WICHTIG!! Damit die veraenderte Form beruecksichtigt wird!?
    repaint();
  }

  /**
   * Verarbeitet die Mausrad-Aktion, indem gezoomed wird.
   * @param e Mausrad-Event
   */
  protected void performMouseWheelEvent(MouseWheelEvent e) {
    if ( getContext().getLayerCount() == 0 )
      return;

    int units = e.getUnitsToScroll();
    // Positiver Wert --> Zoom in  --> Faktor < 1
    // Negativer Wert --> Zoom out --> Faktir > 1
    double  zFactor = units > 0 ? 1/1.2 : 1.2;

    // Fenster-Koordinaten zu Karten-Koordinaten transformieren
    Point2D mapCoord = getTransform().transform( e.getPoint(), null );
    // Relative Position des Mauszeigers zum Kartenausschnitt
    // -> Nach Zoom soll dieselbe Kartenposition unterhalb des Mauszeigers
    //    erscheinen, wie vor dem Zoom
    double  relX = (mapCoord.getX()-getMapArea().getMinX()) / getMapArea().getWidth();
    double  relY = (mapCoord.getY()-getMapArea().getMinY()) / getMapArea().getHeight();

    // Neuen Karten-Ausschnitt berechnen
    Coordinate ll = new Coordinate( mapCoord.getX()-getMapArea().getWidth()*relX*zFactor,
                                    mapCoord.getY()-getMapArea().getHeight()*relY*zFactor );
    Coordinate ur = new Coordinate( mapCoord.getX()+getMapArea().getWidth()*(1-relX)*zFactor,
                                    mapCoord.getY()+getMapArea().getHeight()*(1-relY)*zFactor );
    setMapArea( new Envelope(ll,ur) );
    repaint();
  }

  /**
   * Verarbeitet die Maus-Ziehen-Aktion, indem der Kartenausschnitt verschoben wird.
   * @param e Maus-Event
   */
  protected void performMouseDraggedEvent(Point2D pressedPos, Envelope pressedEnv, MouseEvent e) {
    if ( getContext().getLayerCount() == 0 )
      return;
    // Fenster-Koordinaten zu Karten-Koordinaten transformieren
    Point2D pressedCoord = getTransform().transform( pressedPos, null );
    Point2D newCoord     = getTransform().transform( e.getPoint(), null );
    // Veraenderung bzgl. der urspruenglichen Position errechnen
    double deltaX = pressedCoord.getX() - newCoord.getX();
    double deltaY = pressedCoord.getY() - newCoord.getY();
    // Urspruenglichen Ausschnitt entsprechend der Veraenderung verschieben
    Envelope newArea = new Envelope(pressedEnv);
    newArea.translate(deltaX, deltaY);
    setMapArea( newArea );
    repaint();
  }

  /**
   * Berechnet die Transformation zwischen Fenster- und Karten-Koordinaten
   * neu.
   */
  protected void resetTransform() {
    if ( getMapArea() == null || getWidth() == 0 || getHeight() == 0 )
      return;
    this.transform =  new AffineTransform(
        // Genauso wie die Fenster-Koordinaten, werden die Latitude-Koordinaten
        // nach unten (Sueden) hin groesser
        // --> positive Verschiebung
        getMapArea().getWidth()/getWidth(),
        // keine Verzerrung
        0.0,
        0.0,
        // Waehrend die Fenster-Koordinaten nach unten hin groesser werden,
        // werden Longitude-Koordinaten werden nach Sueden hin keiner
        // --> negative Verschiebung
        -getMapArea().getHeight()/getHeight(),
        // Die Latitude-Koordinaten werden nach Osten hin groesser
        // --> obere linke Ecke des Fensters hat also den Minimalwert
        getMapArea().getMinX(),
        // Die Longitude-Koordinaten werden nach Norden hin groesser
        // --> obere linke Ecke des Fensters hat also den Maximalwert
        getMapArea().getMaxY()
    );
  }

  /**
   * Liefert eine affine Transformation, um von den Fenster-Koordinaten in die
   * Karten-Koordinaten (Lat/Lon) umzurechnen.
   * @return <code>null</code> wenn noch keine Karte angezeigt wird
   */
  public AffineTransform getTransform() {
    if ( transform != null )
      return new AffineTransform(transform);
    return null;
  }

  /**
   * Setzt die sichtbare Karte. Danach wird die {@linkplain AffineTransform Transformation}
   * zwischen Fenster-Koordinaten und Karten-Koordinaten neu berechnet.
   * @param env neuer Kartenausschnitt
   * @see #resetTransform()
   * @see #getTransform()
   */
  public void setMapArea(Envelope env) {
    super.setMapArea(env);
    resetTransform();
  }

  /**
   * Reagiert auf Maus-Klicks mit Zoom-In (Linksklick) und Zoom-Out (Rechtsklick).
   * Alle anderen Klicks werden ignoriert.
   */
  public void mouseClicked(MouseEvent e) {
    // wenn noch kein Layer dargestellt wird, nichts machen
    if ( getContext().getLayerCount() == 0 )
      return;

    switch ( e.getButton() ) {
      // Linksklick --> Zoom-In
      case MouseEvent.BUTTON1: setState( this.ZoomIn ); break;
      // Rechtsklick --> Zoom-Out
      case MouseEvent.BUTTON3: setState( this.ZoomOut ); break;
      // Sonst nix
      default: return;
    }
    super.mouseClicked(e);
    // In super.mouseClicked(.) wird (nach Zoom) die MapArea neu gesetzt, aber
    // leider ohne setMapArea(.) aufzurufen, sondern indem einfach die Variable
    // 'mapArea' neu belegt wird
    // --> Transform muss neu berechnet werden
    resetTransform();
  }

  /**
   * In <code>super.paintComponent(.)</code> wird unter gewissen Umstaenden die
   * MapArea neu gesetzt, aber leider ohne {@link #setMapArea(Envelope)} aufzurufen,
   * sondern indem einfach die Variable <code>mapArea</code> neu belegt wird.
   * Damit auch die Transformation an den neuen Kartenbereich angepasst wird,
   * muss diese Methode ueberschrieben werden.
   * @param g Graphics
   * @see #resetTransform()
   */
  protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    resetTransform();
  }

}