package tim.prune.gui.map;

import java.awt.*;
import java.awt.event.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.awt.image.ImageObserver;
import java.util.List;

import javax.swing.*;

import tim.prune.App;
import tim.prune.DataSubscriber;
import tim.prune.FunctionLibrary;
import tim.prune.GenericFunction;
import tim.prune.I18nManager;
import tim.prune.UpdateMessageBroker;
import tim.prune.cmd.EditPointCmd;
import tim.prune.cmd.InsertPointCmd;
import tim.prune.config.ColourScheme;
import tim.prune.config.Config;
import tim.prune.data.*;
import tim.prune.function.DeleteCurrentPoint;
import tim.prune.function.Describer;
import tim.prune.function.compress.MarkPointsInRectangleFunction;
import tim.prune.function.edit.FieldEdit;
import tim.prune.gui.IconManager;
import tim.prune.gui.MultiStateCheckBox;
import tim.prune.gui.colour.PointColourer;
import tim.prune.gui.colour.WaypointColours;
import tim.prune.gui.colour.WaypointSymbolPainter;
import tim.prune.tips.TipManager;

/**
 * Class for the map canvas, to display a background map and draw on it
 */
public class MapCanvas extends JPanel implements MouseListener, MouseMotionListener, DataSubscriber,
	KeyListener, MouseWheelListener, TileConsumer
{
	/** App object for callbacks */
	private final App _app;
	/** Track object */
	private final Track _track;
	/** TrackInfo object */
	private final TrackInfo _trackInfo;
	/** Selection object */
	private final Selection _selection;
	/** Object to keep track of midpoints */
	private final MidpointData _midpoints;
	/** Index of point clicked at mouseDown */
	private int _clickedPoint = -1;
	/** Previously selected point */
	private int _prevSelectedPoint = -1;
	/** Tile manager */
	private final MapTileManager _tileManager = new MapTileManager(this);
	/** Image to display */
	private BufferedImage _mapImage = null;
	/** Second image for drawing track (only needed for alpha blending) */
	private BufferedImage _trackImage = null;
	/** Slider for transparency */
	private JSlider _transparencySlider = null;
	/** Checkbox for scale bar */
	private final JCheckBox _scaleCheckBox;
	/** Checkbox for maps */
	private final JCheckBox _mapCheckBox;
	/** Checkbox for autopan */
	private final JCheckBox _autopanCheckBox;
	/** Checkbox for connecting track points */
	private final MultiStateCheckBox _connectCheckBox;
	/** Checkbox for enable edit mode */
	private final JCheckBox _editmodeCheckBox;
	/** Right-click popup menu */
	private JPopupMenu _popup = null;
	/** Top component panel */
	private final JPanel _topPanel;
	/** Side component panel */
	private final JPanel _sidePanel;
	/** Scale bar */
	private ScaleBar _scaleBar = null;
	/* Data */
	private DoubleRange _latRange = null, _lonRange = null;

	private boolean _recalculate = false;
	/** Flag to check bounds on next paint */
	private boolean _checkBounds = false;
	/** Map position */
	private final MapPosition _mapPosition;
	/** coordinates of drag from point */
	private int _dragFromX = -1, _dragFromY = -1;
	/** coordinates of drag to point */
	private int _dragToX = -1, _dragToY = -1;
	/** coordinates of popup menu */
	private int _popupMenuX = -1, _popupMenuY = -1;
	/** Current drawing mode */
	private DrawMode _drawMode = DrawMode.DEFAULT;
	/** Current waypoint icon definition */
	private WpIconDefinition _waypointIconDefinition = null;
	/** Colours for waypoint icons */
	private final WaypointColours _waypointColours = new WaypointColours();
	/** Remember whether map is being drawn with empty track or not */
	private boolean _emptyTrack = true;

	/** Scaling factor of display from OS */
	private double _lastScale = 1.0;

	/** Constant for click sensitivity when selecting nearest point */
	private static final int CLICK_SENSITIVITY = 10;
	/** Constant for pan distance from key presses */
	private static final int PAN_DISTANCE = 20;
	/** Constant for pan distance from autopan */
	private static final int AUTOPAN_DISTANCE = 75;

	// Drawing modes
	private enum DrawMode {DEFAULT, ZOOM_RECT, DRAW_POINTS_START, DRAW_POINTS_CONT, DRAG_POINT,
		CREATE_MIDPOINT, MARK_RECTANGLE_INSIDE, MARK_RECTANGLE_OUTSIDE};

	private static final int INDEX_UNKNOWN  = -2;


	/**
	 * Constructor
	 * @param inApp App object for callbacks
	 */
	public MapCanvas(App inApp)
	{
		_app = inApp;
		_trackInfo = _app.getTrackInfo();
		_track = _trackInfo.getTrack();
		_selection = _trackInfo.getSelection();
		_midpoints = new MidpointData();
		_mapPosition = new MapPosition();
		addMouseListener(this);
		addMouseMotionListener(this);
		addMouseWheelListener(this);
		addKeyListener(this);

		// Make listener for changes to controls
		ItemListener itemListener = e -> {
			_recalculate = true;
			repaint();
		};
		// Make special listener for changes to map checkbox
		ItemListener mapCheckListener = e -> {
			_tileManager.clearMemoryCaches();
			_recalculate = true;
			_app.getConfig().setConfigBoolean(Config.KEY_SHOW_MAP, e.getStateChange() == ItemEvent.SELECTED);
			UpdateMessageBroker.informSubscribers(); // to let menu know
			// If the track is only partially visible and you turn the map off, make the track fully visible again
			if (e.getStateChange() == ItemEvent.DESELECTED && _transparencySlider.getValue() < 0) {
				_transparencySlider.setValue(0);
			}
		};
		_topPanel = new OverlayPanel();
		_topPanel.setLayout(new FlowLayout());
		// Make slider for transparency
		_transparencySlider = new JSlider(-6, 6, 0);
		_transparencySlider.setPreferredSize(new Dimension(100, 20));
		_transparencySlider.setMajorTickSpacing(1);
		_transparencySlider.setSnapToTicks(true);
		_transparencySlider.setOpaque(false);
		_transparencySlider.setValue(0);
		_transparencySlider.addChangeListener(e -> {
			int val = _transparencySlider.getValue();
			if (val == 1 || val == -1) {
				_transparencySlider.setValue(0);
			} else {
				_recalculate = true;
				repaint();
			}
		});
		_transparencySlider.setFocusable(false); // stop slider from stealing keyboard focus
		_topPanel.add(_transparencySlider);
		IconManager iconManager = _app.getIconManager();
		// Add checkbox button for enabling scale bar
		_scaleCheckBox = new JCheckBox(iconManager.getImageIcon(IconManager.SCALEBAR_BUTTON), true);
		_scaleCheckBox.setSelectedIcon(iconManager.getImageIcon(IconManager.SCALEBAR_BUTTON_ON));
		_scaleCheckBox.setOpaque(false);
		_scaleCheckBox.setToolTipText(I18nManager.getText("menu.map.showscalebar"));
		_scaleCheckBox.addItemListener(e -> _scaleBar.setVisible(_scaleCheckBox.isSelected()));
		_scaleCheckBox.setFocusable(false); // stop button from stealing keyboard focus
		_topPanel.add(_scaleCheckBox);
		// Add checkbox button for enabling maps or not
		_mapCheckBox = new JCheckBox(iconManager.getImageIcon(IconManager.MAP_BUTTON), false);
		_mapCheckBox.setSelectedIcon(iconManager.getImageIcon(IconManager.MAP_BUTTON_ON));
		_mapCheckBox.setOpaque(false);
		_mapCheckBox.setToolTipText(I18nManager.getText("menu.map.showmap"));
		_mapCheckBox.addItemListener(mapCheckListener);
		_mapCheckBox.setFocusable(false); // stop button from stealing keyboard focus
		_topPanel.add(_mapCheckBox);
		// Add checkbox button for enabling autopan or not
		_autopanCheckBox = new JCheckBox(iconManager.getImageIcon(IconManager.AUTOPAN_BUTTON), true);
		_autopanCheckBox.setSelectedIcon(iconManager.getImageIcon(IconManager.AUTOPAN_BUTTON_ON));
		_autopanCheckBox.setOpaque(false);
		_autopanCheckBox.setToolTipText(I18nManager.getText("menu.map.autopan"));
		_autopanCheckBox.addItemListener(itemListener);
		_autopanCheckBox.setFocusable(false); // stop button from stealing keyboard focus
		_topPanel.add(_autopanCheckBox);
		// Add checkbox button for connecting points or not
		_connectCheckBox = new MultiStateCheckBox(4);
		_connectCheckBox.setIcon(0, iconManager.getImageIcon(IconManager.POINTS_WITH_ARROWS_BUTTON));
		_connectCheckBox.setIcon(1, iconManager.getImageIcon(IconManager.POINTS_HIDDEN_BUTTON));
		_connectCheckBox.setIcon(2, iconManager.getImageIcon(IconManager.POINTS_CONNECTED_BUTTON));
		_connectCheckBox.setIcon(3, iconManager.getImageIcon(IconManager.POINTS_DISCONNECTED_BUTTON));
		_connectCheckBox.setCurrentState(0);
		_connectCheckBox.setOpaque(false);
		_connectCheckBox.setToolTipText(I18nManager.getText("menu.map.connect"));
		_connectCheckBox.addItemListener(itemListener);
		_connectCheckBox.setFocusable(false); // stop button from stealing keyboard focus
		_topPanel.add(_connectCheckBox);

		// Add checkbox button for edit mode or not
		_editmodeCheckBox = new JCheckBox(iconManager.getImageIcon(IconManager.EDIT_MODE_BUTTON), false);
		_editmodeCheckBox.setSelectedIcon(iconManager.getImageIcon(IconManager.EDIT_MODE_BUTTON_ON));
		_editmodeCheckBox.setOpaque(false);
		_editmodeCheckBox.setToolTipText(I18nManager.getText("menu.map.editmode"));
		_editmodeCheckBox.addItemListener(itemListener);
		_editmodeCheckBox.setFocusable(false); // stop button from stealing keyboard focus
		_topPanel.add(_editmodeCheckBox);

		// Add zoom in, zoom out buttons
		_sidePanel = new OverlayPanel();
		_sidePanel.setLayout(new BoxLayout(_sidePanel, BoxLayout.Y_AXIS));
		JButton zoomInButton = new JButton(iconManager.getImageIcon(IconManager.ZOOM_IN_BUTTON));
		zoomInButton.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
		zoomInButton.setContentAreaFilled(false);
		zoomInButton.setToolTipText(I18nManager.getText("menu.map.zoomin"));
		zoomInButton.addActionListener(e -> zoomIn());
		zoomInButton.setFocusable(false); // stop button from stealing keyboard focus
		_sidePanel.add(zoomInButton);
		JButton zoomOutButton = new JButton(iconManager.getImageIcon(IconManager.ZOOM_OUT_BUTTON));
		zoomOutButton.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
		zoomOutButton.setContentAreaFilled(false);
		zoomOutButton.setToolTipText(I18nManager.getText("menu.map.zoomout"));
		zoomOutButton.addActionListener(e -> zoomOut());
		zoomOutButton.setFocusable(false); // stop button from stealing keyboard focus
		_sidePanel.add(zoomOutButton);

		// Bottom panel for scale bar
		_scaleBar = new ScaleBar(_app.getConfig());

		// add control panels to this one
		setLayout(new BorderLayout());
		_topPanel.setVisible(false);
		_sidePanel.setVisible(false);
		add(_topPanel, BorderLayout.NORTH);
		add(_sidePanel, BorderLayout.WEST);
		add(_scaleBar, BorderLayout.SOUTH);
		// Make popup menu
		makePopup();
		// Get currently selected map from Config, pass to MapTileManager
		_tileManager.setMapSource(_app.getConfig().getConfigInt(Config.KEY_MAPSOURCE_INDEX));
		// Update display settings
		dataUpdated(MAPSERVER_CHANGED);
	}


	/**
	 * Make the popup menu for right-clicking the map
	 */
	private void makePopup()
	{
		_popup = new JPopupMenu();
		JMenuItem zoomInItem = new JMenuItem(I18nManager.getText("menu.map.zoomin"));
		zoomInItem.addActionListener(e -> {
			panMap((_popupMenuX - getScaledWidth()/2)/2, (_popupMenuY - getScaledHeight()/2)/2);
			zoomIn();
		});
		_popup.add(zoomInItem);
		JMenuItem zoomOutItem = new JMenuItem(I18nManager.getText("menu.map.zoomout"));
		zoomOutItem.addActionListener(e -> {
			panMap(-(_popupMenuX - getScaledWidth()/2), -(_popupMenuY - getScaledHeight()/2));
			zoomOut();
		});
		_popup.add(zoomOutItem);
		JMenuItem zoomFullItem = new JMenuItem(I18nManager.getText("menu.map.zoomfull"));
		zoomFullItem.addActionListener(e -> {
			zoomToFit();
			_recalculate = true;
			repaint();
		});
		_popup.add(zoomFullItem);
		_popup.addSeparator();
		// Set background
		JMenuItem setMapBgItem = new JMenuItem(
			I18nManager.getText(FunctionLibrary.FUNCTION_SET_MAP_BG.getNameKey()));
		setMapBgItem.addActionListener(e -> FunctionLibrary.FUNCTION_SET_MAP_BG.begin());
		_popup.add(setMapBgItem);
		// new point option
		JMenuItem newPointItem = new JMenuItem(I18nManager.getText("menu.map.newpoint"));
		newPointItem.addActionListener(e -> insertPoint(createPointFromClick(_popupMenuX, _popupMenuY, true), -1));
		_popup.add(newPointItem);
		// draw point series
		JMenuItem drawPointsItem = new JMenuItem(I18nManager.getText("menu.map.drawpoints"));
		drawPointsItem.addActionListener(e -> _drawMode = DrawMode.DRAW_POINTS_START);
		_popup.add(drawPointsItem);
	}


	/**
	 * Zoom to fit the current data area
	 */
	private void zoomToFit()
	{
		if (_track.getNumPoints() > 0)
		{
			_latRange = _track.getLatRange();
			_lonRange = _track.getLonRange();
		}
		if (_latRange == null || _lonRange == null
			|| !_latRange.hasData() || !_lonRange.hasData())
		{
			setDefaultLatLonRange();
		}
		DoubleRange xRange = new DoubleRange(MapUtils.getXFromLongitude(_lonRange.getMinimum()),
			MapUtils.getXFromLongitude(_lonRange.getMaximum()));
		DoubleRange yRange = new DoubleRange(MapUtils.getYFromLatitude(_latRange.getMinimum()),
			MapUtils.getYFromLatitude(_latRange.getMaximum()));
		_mapPosition.zoomToXY(xRange.getMinimum(), xRange.getMaximum(), yRange.getMinimum(), yRange.getMaximum(),
			getScaledWidth(), getScaledHeight());
		showZoomLevel();
	}

	/**
	 * Track data is empty, so find a default area on the map to show
	 */
	private void setDefaultLatLonRange()
	{
		String storedRange = _app.getConfig().getConfigString(Config.KEY_LATLON_RANGE);
		// Parse it into four latlon values
		try
		{
			String[] values = storedRange.split(";");
			if (values.length == 4)
			{
				final double lat1 = Double.parseDouble(values[0]);
				final double lat2 = Double.parseDouble(values[1]);
				if (lat1 >= -90.0 && lat1 <= 90.0 && lat2 >= -90.0 && lat2 <= 90.0 && lat1 != lat2)
				{
					_latRange = new DoubleRange(lat1, lat2);
					final double lon1 = Double.parseDouble(values[2]);
					final double lon2 = Double.parseDouble(values[3]);
					if (lon1 >= -180.0 && lon1 <= 180.0 && lon2 >= -180.0 && lon2 <= 180.0 && lon1 != lon2)
					{
						_lonRange = new DoubleRange(lon1, lon2);
						return;
					}
				}
			}
		}
		catch (Exception ignored) {}
		_latRange = new DoubleRange(45.8, 47.9);
		_lonRange = new DoubleRange(5.9, 10.6);
	}

	/**
	 * Paint method
	 * @see java.awt.Canvas#paint(java.awt.Graphics)
	 */
	public void paint(Graphics inG)
	{
		super.paint(inG);
		if (_mapImage != null && (_mapImage.getWidth() != getScaledWidth() || _mapImage.getHeight() != getScaledHeight())) {
			_mapImage = null;
		}
		final boolean showMap = _app.getConfig().getConfigBoolean(Config.KEY_SHOW_MAP);
		final boolean showSomething = _track.getNumPoints() > 0 || showMap;
		if (showSomething)
		{
			// Check for autopan if enabled / necessary
			if (_autopanCheckBox.isSelected())
			{
				int selectedPoint = _selection.getCurrentPointIndex();
				if (selectedPoint >= 0 && _dragFromX == -1 && selectedPoint != _prevSelectedPoint)
				{
					autopanToPoint(selectedPoint);
				}
				_prevSelectedPoint = selectedPoint;
			}

			// Recognise empty map position, if no data has been loaded
			if (_emptyTrack && _track.getNumPoints() > 1)
			{
				zoomToFit();
				_recalculate = true;
			}
			_emptyTrack = (_track.getNumPoints() == 0);

			// Draw the map contents if necessary
			if (_mapImage == null || _recalculate)
			{
				paintMapContents();
				_scaleBar.updateScale(_mapPosition.getZoom(), _mapPosition.getYFromPixels(0, 0));
			}
			// Draw the prepared image onto the panel
			if (_mapImage != null)
			{
				Graphics2D g2 = (Graphics2D) (inG.create());
				final boolean allowScaling = _app.getConfig().getConfigBoolean(Config.KEY_OSSCALING);
				final double preScale = allowScaling ? 1.0 : g2.getTransform().getScaleX();
				if (preScale != _lastScale)
				{
					_lastScale = preScale;
					_mapPosition.setDisplayScaling(_lastScale);
					_scaleBar.setDisplayScaling(_lastScale);
				}
				if (!allowScaling)
				{
					AffineTransform at = g2.getTransform();
					final double xTranslate = at.getTranslateX();
					final double yTranslate = at.getTranslateY();
					at.setToScale(1.0, 1.0);
					at.translate(xTranslate, yTranslate);
					g2.setTransform(at);
				}
				g2.drawImage(_mapImage, 0, 0, getScaledWidth(), getScaledHeight(), null);
			}

			switch (_drawMode)
			{
				case DRAG_POINT:
					DataPoint currPoint = _track.getPoint(_selection.getCurrentPointIndex());
					if (currPoint != null)
					{
						final int currPointIndex = _selection.getCurrentPointIndex();
						if (currPoint.isWaypoint()) {
							drawDragLines(inG, currPointIndex, currPointIndex);
						}
						else {
							drawDragLines(inG, currPointIndex - 1, currPointIndex + 1);
						}
					}
					break;

				case CREATE_MIDPOINT:
					drawDragLines(inG, _clickedPoint-1, _clickedPoint);
					break;

				case ZOOM_RECT:
				case MARK_RECTANGLE_INSIDE:
				case MARK_RECTANGLE_OUTSIDE:
					if (_dragFromX != -1 && _dragFromY != -1)
					{
						// Draw the zoom rectangle if necessary
						inG.setColor(Color.RED);
						inG.drawLine(_dragFromX, _dragFromY, _dragFromX, _dragToY);
						inG.drawLine(_dragFromX, _dragFromY, _dragToX, _dragFromY);
						inG.drawLine(_dragToX, _dragFromY, _dragToX, _dragToY);
						inG.drawLine(_dragFromX, _dragToY, _dragToX, _dragToY);
					}
					break;

				case DRAW_POINTS_CONT:
					int prevIndex = _track.getNumPoints() - 1;
					if (prevIndex >= 0)
					{
						// draw line to mouse position to show drawing mode
						inG.setColor(_app.getConfig().getColourScheme().getColour(ColourScheme.IDX_POINT));
						int px = getWidth() / 2 + (int) (_mapPosition.getXFromCentre(_track.getX(prevIndex)) / _lastScale);
						int py = getHeight() / 2 + (int) (_mapPosition.getYFromCentre(_track.getY(prevIndex)) / _lastScale);
						inG.drawLine(px, py, _dragToX, _dragToY);
					}
					break;
				case DRAW_POINTS_START:
				case DEFAULT:
					break;
			}
		}
		else
		{
			final ColourScheme colourScheme = _app.getConfig().getColourScheme();
			inG.setColor(colourScheme.getColour(ColourScheme.IDX_BACKGROUND));
			inG.fillRect(0, 0, getScaledWidth(), getScaledHeight());
			inG.setColor(colourScheme.getColour(ColourScheme.IDX_TEXT));
			inG.drawString(I18nManager.getText("display.nodata"), 50, getScaledHeight()/2);
			_scaleBar.updateScale(-1, 0);
		}
		// enable or disable panels
		_topPanel.setVisible(showSomething);
		_sidePanel.setVisible(showSomething);
		// Draw slider etc on top
		paintChildren(inG);
	}

	/**
	 * @return true if the currently selected point is visible, false if off-screen or nothing selected
	 */
	private boolean isCurrentPointVisible()
	{
		if (_trackInfo.getCurrentPoint() == null) {return false;}
		final int selectedPoint = _selection.getCurrentPointIndex();
		final int xFromCentre = Math.abs(_mapPosition.getXFromCentre(_track.getX(selectedPoint)));
		if (xFromCentre > (getScaledWidth()/2)) {return false;}
		final int yFromCentre = Math.abs(_mapPosition.getYFromCentre(_track.getY(selectedPoint)));
		return yFromCentre < (getScaledHeight()/2);
	}

	/**
	 * If the specified point isn't visible, pan to it
	 * @param inIndex index of selected point
	 */
	private void autopanToPoint(int inIndex)
	{
		int px = getWidth() / 2 + _mapPosition.getXFromCentre(_track.getX(inIndex));
		int py = getHeight() / 2 + _mapPosition.getYFromCentre(_track.getY(inIndex));
		int panX = 0;
		int panY = 0;
		if (px < PAN_DISTANCE) {
			panX = px - AUTOPAN_DISTANCE;
		}
		else if (px > (getWidth()-PAN_DISTANCE)) {
			panX = AUTOPAN_DISTANCE + px - getWidth();
		}
		if (py < (2*PAN_DISTANCE)) {
			panY = py - AUTOPAN_DISTANCE;
		}
		if (py > (getHeight()-PAN_DISTANCE)) {
			panY = AUTOPAN_DISTANCE + py - getHeight();
		}
		if (panX != 0 || panY != 0) {
			_mapPosition.pan(panX, panY);
		}
	}

	/**
	 * Paint the map tiles and the points on to the _mapImage
	 */
	private void paintMapContents()
	{
		if (_mapImage == null || _mapImage.getWidth() != getScaledWidth() || _mapImage.getHeight() != getScaledHeight())
		{
			_mapImage = new BufferedImage(getScaledWidth(), getScaledHeight(), BufferedImage.TYPE_INT_RGB);
		}

		Graphics g = _mapImage.getGraphics();
		// Set antialiasing according to config
		final Config config = _app.getConfig();
		final ColourScheme colourScheme = config.getColourScheme();
		((Graphics2D) g).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
			config.getConfigBoolean(Config.KEY_ANTIALIAS) ? RenderingHints.VALUE_ANTIALIAS_ON : RenderingHints.VALUE_ANTIALIAS_OFF);
		// Clear to background
		g.setColor(colourScheme.getColour(ColourScheme.IDX_BACKGROUND));
		g.fillRect(0, 0, getScaledWidth(), getScaledHeight());

		// Check whether maps are on or not
		final boolean showMap = config.getConfigBoolean(Config.KEY_SHOW_MAP);
		_mapCheckBox.setSelected(showMap);
		// Check whether disk cache is on or not
		final boolean usingDiskCache = config.getConfigString(Config.KEY_DISK_CACHE) != null;
		// Show tip to recommend setting up a cache
		if (showMap && !usingDiskCache && config.getConfigBoolean(Config.KEY_ONLINE_MODE))
		{
			SwingUtilities.invokeLater(() -> _app.showTip(TipManager.Tip_UseAMapCache));
		}

		_recalculate = false;
		// Only get map tiles if selected
		if (showMap)
		{
			// init tile cacher
			_tileManager.centreMap(_mapPosition.getZoom(), _mapPosition.getCentreTileX(), _mapPosition.getCentreTileY());

			if (_mapImage == null) return;

			if (_tileManager.isOverzoomed())
			{
				// display overzoom message
				g.setColor(colourScheme.getColour(ColourScheme.IDX_TEXT));
				g.drawString(I18nManager.getText("map.overzoom"), 50, getScaledHeight()/2);
			}
			else
			{
				int numLayers = _tileManager.getNumLayers();
				// Loop over tiles drawing each one
				int[] tileIndices = _mapPosition.getTileIndices(getScaledWidth(), getScaledHeight());
				int[] pixelOffsets = _mapPosition.getDisplayOffsets(getScaledWidth(), getScaledHeight());
				for (int tileX = tileIndices[0]; tileX <= tileIndices[1]; tileX++)
				{
					int x = (tileX - tileIndices[0]) * 256 - pixelOffsets[0];
					for (int tileY = tileIndices[2]; tileY <= tileIndices[3]; tileY++)
					{
						int y = (tileY - tileIndices[2]) * 256 - pixelOffsets[1];
						// Loop over layers
						for (int l=0; l<numLayers; l++)
						{
							Image image = _tileManager.getTile(l, tileX, tileY, true, _app.getConfig());
							if (image != null) {
								g.drawImage(image, x, y, 256, 256, (img, flags, px, py, width, height) -> checkPaintedTile(flags));
							}
						}
					}
				}

				// Make maps brighter / fainter according to slider
				final int brightnessIndex = Math.max(1, _transparencySlider.getValue()) - 1;
				if (brightnessIndex > 0)
				{
					final int[] alphas = {0, 40, 80, 120, 160, 210};
					Color bgColor = colourScheme.getColour(ColourScheme.IDX_BACKGROUND);
					bgColor = new Color(bgColor.getRed(), bgColor.getGreen(), bgColor.getBlue(), alphas[brightnessIndex]);
					g.setColor(bgColor);
					g.fillRect(0, 0, getScaledWidth(), getScaledHeight());
				}
			}
		}

		// Work out track opacity according to slider
		final float[] opacities = {1.0f, 0.75f, 0.5f, 0.3f, 0.15f, 0.0f};
		float trackOpacity = 1.0f;
		if (_transparencySlider.getValue() < 0) {
			trackOpacity = opacities[-1 - _transparencySlider.getValue()];
		}

		if (trackOpacity > 0.0f)
		{
			// Paint the track points on top
			boolean pointsPainted = true;
			try
			{
				if (trackOpacity > 0.9f)
				{
					// Track is fully opaque, just draw it directly
					pointsPainted = paintPoints(g);
					_trackImage = null;
				}
				else
				{
					// Track is partly transparent, so use a separate BufferedImage
					if (_trackImage == null || _trackImage.getWidth() != getScaledWidth() || _trackImage.getHeight() != getScaledHeight())
					{
						_trackImage = new BufferedImage(getScaledWidth(), getScaledHeight(), BufferedImage.TYPE_INT_ARGB);
					}
					// Clear to transparent
					Graphics2D gTrack = _trackImage.createGraphics();
					gTrack.setComposite(AlphaComposite.getInstance(AlphaComposite.CLEAR, 0.0f));
					gTrack.fillRect(0, 0, getScaledWidth(), getScaledHeight());
					gTrack.setPaintMode();
					// Draw the track onto this separate image
					pointsPainted = paintPoints(gTrack);
					((Graphics2D) g).setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, trackOpacity));
					g.drawImage(_trackImage, 0, 0, null);
				}
			}
			catch (NullPointerException | ArrayIndexOutOfBoundsException ignored) {}
			// ignore, probably due to data being changed during drawing

			// Zoom to fit if no points found
			if (!pointsPainted && _checkBounds)
			{
				zoomToFit();
				_recalculate = true;
				repaint();
			}
		}

		// free g
		g.dispose();

		_checkBounds = false;
		// enable / disable transparency slider
		_transparencySlider.setEnabled(showMap);
	}


	private boolean checkPaintedTile(int flags)
	{
		if ((flags & ImageObserver.ALLBITS) == 0) {
			tilesUpdated(true);
			return true;
		}
		return false;
	}


	/**
	 * Paint the points using the given graphics object
	 * @param inG Graphics object to use for painting
	 * @return true if any points or lines painted
	 */
	private boolean paintPoints(Graphics inG)
	{
		// Set up colours
		final ColourScheme cs = _app.getConfig().getColourScheme();
		final Color pointColour  = cs.getColour(ColourScheme.IDX_POINT);
		final Color rangeColour  = cs.getColour(ColourScheme.IDX_SELECTION);
		final Color currentColour = cs.getColour(ColourScheme.IDX_PRIMARY);
		final Color secondColour = cs.getColour(ColourScheme.IDX_SECONDARY);
		final Color textColour   = cs.getColour(ColourScheme.IDX_TEXT);
		final PointColourer pointColourer = _app.getPointColourer();

		final int winWidth  = getScaledWidth();
		final int winHeight = getScaledHeight();
		final int halfWinWidth  = winWidth / 2;
		final int halfWinHeight = winHeight / 2;

		final int numPoints = _track.getNumPoints();
		final int[] xPixels = new int[numPoints];
		final int[] yPixels = new int[numPoints];

		final int pointSeparationForArrowsSqd = 400;
		final int pointSeparation1dForArrows = (int) (Math.sqrt(pointSeparationForArrowsSqd) * 0.7);
		final int hugePointSeparationForArrows = 120;

		// try to set line width for painting
		if (inG instanceof Graphics2D)
		{
			int lineWidth = _app.getConfig().getConfigInt(Config.KEY_LINE_WIDTH);
			if (lineWidth < 1 || lineWidth > 4) {lineWidth = 2;}
			((Graphics2D) inG).setStroke(new BasicStroke(lineWidth));
		}

		boolean pointsPainted = false;
		// draw track points
		inG.setColor(pointColour);
		int prevX = -1, prevY = -1;
		final int connectState = _connectCheckBox.getCurrentState();
		final boolean drawLines = (connectState != 3);  // 0, 1 or 2
		final boolean drawPoints = (connectState != 1); // 0, 2 or 3
		final boolean drawArrows = (connectState == 0); // 0

		boolean prevPointVisible = false;
		boolean anyWaypoints = false;
		boolean drawnLastArrow = false;	// avoid painting arrows on adjacent lines, looks too busy
		for (int i=0; i<numPoints; i++)
		{
			// Calculate pixel position of point from its x, y coordinates
			int px = halfWinWidth  + _mapPosition.getXFromCentre(_track.getX(i));
			int py = halfWinHeight + _mapPosition.getYFromCentre(_track.getY(i));
			px = wrapLongitudeValue(px, winWidth, _mapPosition.getZoom());
			// Remember these calculated pixel values so they don't have to be recalculated
			xPixels[i] = px; yPixels[i] = py;

			final boolean currPointVisible = px >= 0 && px < winWidth && py >= 0 && py < winHeight;
			final boolean isWaypoint = _track.getPoint(i).isWaypoint();
			anyWaypoints = anyWaypoints || isWaypoint;
			if (!isWaypoint)
			{
				if (currPointVisible || (drawLines && prevPointVisible))
				{
					// For track points, work out which colour to use
					if (_trackInfo.isPointMarkedForDeletion(i)) {
						inG.setColor(currentColour);
					}
					else {
						inG.setColor(pointColourer.getColour(i));
					}

					// Draw rectangle for track point if it's visible
					if (currPointVisible)
					{
						if (drawPoints) {
							inG.drawRect(px-2, py-2, 3, 3);
						}
						pointsPainted = true;
					}
				}

				// Connect track points if either of them are visible
				if (drawLines
				 && (currPointVisible || prevPointVisible)
				 && !(prevX == -1 && prevY == -1)
				 && !_track.getPoint(i).getSegmentStart())
				{
					inG.drawLine(prevX, prevY, px, py);
					pointsPainted = true;

					// Now consider whether we need to draw an arrow as well
					if (drawArrows)
					{
						final double pointDist = Math.max(Math.abs(prevX - px), Math.abs(prevY - py));
						final int separationLimit = (drawnLastArrow ? hugePointSeparationForArrows : pointSeparation1dForArrows);
						if (pointDist > separationLimit)
						{
							final double pointSeparationSqd = (prevX-px) * (prevX-px) + (prevY-py) * (prevY-py);
							if (pointSeparationSqd > pointSeparationForArrowsSqd)
							{
								final double midX = (prevX + px) / 2.0;
								final double midY = (prevY + py) / 2.0;
								final boolean midPointVisible = midX >= 0 && midX < winWidth && midY >= 0 && midY < winHeight;
								if (midPointVisible)
								{
									final double alpha = Math.atan2(py - prevY, px - prevX);
									final double MID_TO_VERTEX = 3.0;
									final double arrowX = MID_TO_VERTEX * Math.cos(alpha);
									final double arrowY = MID_TO_VERTEX * Math.sin(alpha);
									final double vertexX = midX + arrowX;
									final double vertexY = midY + arrowY;
									inG.drawLine((int)(midX-arrowX-2*arrowY), (int)(midY-arrowY+2*arrowX), (int)vertexX, (int)vertexY);
									inG.drawLine((int)(midX-arrowX+2*arrowY), (int)(midY-arrowY-2*arrowX), (int)vertexX, (int)vertexY);
								}
								drawnLastArrow = midPointVisible;
							}
						}
						else
						{
							drawnLastArrow = false;
						}
					}
				}
				prevX = px; prevY = py;
			}
			prevPointVisible = currPointVisible;
		}

		// Loop over points, just drawing blobs for waypoints
		inG.setColor(textColour);
		if (anyWaypoints)
		{
			// TODO: Expand font size of inG using _lastScale
			FontMetrics fm = inG.getFontMetrics();
			final int nameHeight = fm.getHeight();
			_waypointColours.setSalt(_app.getConfig().getConfigInt(Config.KEY_WPICON_SALT));
			int numWaypoints = 0;
			for (int i=0; i<_track.getNumPoints(); i++)
			{
				if (_track.getPoint(i).isWaypoint())
				{
					int px = xPixels[i];
					int py = yPixels[i];
					if (px >= 0 && px < winWidth && py >= 0 && py < winHeight)
					{
						if (_waypointIconDefinition == null) {
							inG.fillRect(px-3, py-3, 6, 6);
						}
						else
						{
							ImageIcon icon = _waypointIconDefinition.getImageIcon();
							Color paintColor = _waypointColours.getColourForType(_track.getPoint(i).getFieldValue(Field.WAYPT_TYPE));
							Image painted = WaypointSymbolPainter.paintSymbol(icon, paintColor);
							if (painted != null)
							{
								inG.drawImage(painted, px-_waypointIconDefinition.getXOffset(),
									py-_waypointIconDefinition.getYOffset(), null);
							}
						}
						pointsPainted = true;
						numWaypoints++;
					}
				}
			}
			// Take more care with waypoint names if less than 100 are visible
			final int numNameSteps = (numWaypoints > 100 ? 1 : 4);
			final int numPointSteps = (numWaypoints > 1000 ? 2 : 1);

			// Loop over points again, now draw names for waypoints
			int[] nameXs = {0, 0, 0, 0};
			int[] nameYs = {0, 0, 0, 0};
			for (int i=0; i<_track.getNumPoints(); i += numPointSteps)
			{
				if (_track.getPoint(i).isWaypoint())
				{
					int px = xPixels[i];
					int py = yPixels[i];
					if (px >= 0 && px < winWidth && py >= 0 && py < winHeight)
					{
						// Figure out where to draw waypoint name so it doesn't obscure track
						String waypointName = _track.getPoint(i).getWaypointName();
						int nameWidth = fm.stringWidth(waypointName);
						boolean drawnName = false;
						// Make arrays for coordinates right left up down
						nameXs[0] = px + 2; nameXs[1] = px - nameWidth - 2;
						nameXs[2] = nameXs[3] = px - nameWidth/2;
						nameYs[0] = nameYs[1] = py + (nameHeight/2);
						nameYs[2] = py - 2; nameYs[3] = py + nameHeight + 2;
						for (int extraSpace = 0; extraSpace < numNameSteps && !drawnName; extraSpace++)
						{
							// Shift arrays for coordinates right left up down
							nameXs[0] += 3; nameXs[1] -= 3;
							nameYs[2] -= 3; nameYs[3] += 3;
							// Check each direction in turn right left up down
							for (int a=0; a<4; a++)
							{
								if (nameXs[a] > 0 && (nameXs[a] + nameWidth) < winWidth
									&& nameYs[a] < winHeight && (nameYs[a] - nameHeight) > 0
									&& !MapUtils.overlapsPoints(_mapImage, nameXs[a], nameYs[a], nameWidth, nameHeight, textColour))
								{
									// Found a rectangle to fit - draw name here and quit
									inG.drawString(waypointName, nameXs[a], nameYs[a]);
									drawnName = true;
									break;
								}
							}
						}
					}
				}
			}
		}
		// Loop over points, drawing blobs for photo / audio points
		inG.setColor(secondColour);
		for (int i=0; i<_track.getNumPoints(); i++)
		{
			if (_track.getPoint(i).hasMedia())
			{
				int px = xPixels[i];
				int py = yPixels[i];
				if (px >= 0 && px < winWidth && py >= 0 && py < winHeight)
				{
					inG.drawRect(px-1, py-1, 2, 2);
					inG.drawRect(px-2, py-2, 4, 4);
					pointsPainted = true;
				}
			}
		}

		// Draw selected range
		if (_selection.hasRangeSelected())
		{
			inG.setColor(rangeColour);
			for (int i=_selection.getStart(); i<=_selection.getEnd(); i++)
			{
				int px = xPixels[i];
				int py = yPixels[i];
				inG.drawRect(px-1, py-1, 2, 2);
			}
		}

		// Draw crosshairs at selected point
		int selectedPoint = _selection.getCurrentPointIndex();
		if (selectedPoint >= 0)
		{
			int px = xPixels[selectedPoint];
			int py = yPixels[selectedPoint];
			inG.setColor(currentColour);
			// crosshairs
			inG.drawLine(px, 0, px, winHeight);
			inG.drawLine(0, py, winWidth, py);
		}
		// Return the number of points painted
		return pointsPainted;
	}

	/**
	 * Wrap the given pixel value if appropriate and possible
	 * @param inPx Pixel x coordinate
	 * @param inWinWidth window width in pixels
	 * @param inZoom zoom level
	 * @return modified pixel x coordinate
	 */
	private static int wrapLongitudeValue(int inPx, int inWinWidth, int inZoom)
	{
		if (inPx > inWinWidth)
		{
			// Pixel is too far right, could we wrap it back onto the screen?
			int px = inPx;
			while (px > inWinWidth) {
				px -= (256 << inZoom);
			}
			if (px >= 0) {
				return px; // successfully wrapped back onto the screen
			}
		}
		else if (inPx < 0)
		{
			// Pixel is too far left, could we wrap it back onto the screen?
			int px = inPx;
			while (px < 0) {
				px += (256 << inZoom);
			}
			if (px < inWinWidth) {
				return px; // successfully wrapped back onto the screen
			}
		}
		// Either it's already on the screen or couldn't be wrapped
		return inPx;
	}

	/**
	 * Draw the lines while dragging a point
	 * @param inG graphics object
	 * @param inPrevIndex index of point to draw from
	 * @param inNextIndex index of point to draw to
	 */
	private void drawDragLines(Graphics inG, int inPrevIndex, int inNextIndex)
	{
		inG.setColor(_app.getConfig().getColourScheme().getColour(ColourScheme.IDX_POINT));
		// line from prev point to cursor
		if ((inPrevIndex == inNextIndex) || (inPrevIndex > -1 && !_track.getPoint(inPrevIndex+1).getSegmentStart()))
		{
			final int px = getWidth() / 2 + (int) (_mapPosition.getXFromCentre(_track.getX(inPrevIndex)) / _lastScale);
			final int py = getHeight() / 2 + (int) (_mapPosition.getYFromCentre(_track.getY(inPrevIndex)) / _lastScale);
			inG.drawLine(px, py, _dragToX, _dragToY);
		}
		if (inNextIndex < _track.getNumPoints() && !_track.getPoint(inNextIndex).getSegmentStart())
		{
			final int px = getWidth() / 2 + (int) (_mapPosition.getXFromCentre(_track.getX(inNextIndex)) / _lastScale);
			final int py = getHeight() / 2 + (int) (_mapPosition.getYFromCentre(_track.getY(inNextIndex)) / _lastScale);
			inG.drawLine(px, py, _dragToX, _dragToY);
		}
	}

	/**
	 * Inform that tiles have been updated and the map can be repainted
	 * @param inIsOk true if data loaded ok, false for error
	 */
	public void tilesUpdated(boolean inIsOk)
	{
		_recalculate = true;
		repaint();
	}

	/**
	 * Inform that a cache failure occurred
	 */
	public void reportCacheFailure()
	{
		// Cache can't be used, so disable it - user will be reminded to set it up by the tips
		_app.getConfig().setConfigString(Config.KEY_DISK_CACHE, null);
	}

	/**
	 * Zoom out, if not already at minimum zoom
	 */
	public final void zoomOut()
	{
		_mapPosition.zoomOut();
		_recalculate = true;
		repaint();
		showZoomLevel();
	}

	/**
	 * Zoom in, if not already at maximum zoom
	 */
	public final void zoomIn()
	{
		// See if selected point is currently visible, if so (and autopan on) then autopan after zoom to keep it visible
		boolean wasVisible = _autopanCheckBox.isSelected() && isCurrentPointVisible();
		_mapPosition.zoomIn();
		if (wasVisible && !isCurrentPointVisible()) {
			autopanToPoint(_selection.getCurrentPointIndex());
		}
		_recalculate = true;
		repaint();
		showZoomLevel();
	}

	private void showZoomLevel()
	{
		if (_app.getConfig().getConfigBoolean(Config.KEY_SHOW_ZOOMLEVEL)) {
			UpdateMessageBroker.informSubscribers(I18nManager.getText("display.zoomlevel") + ": " + _mapPosition.getZoom());
		}
	}

	/**
	 * Pan map
	 * @param inDeltaX x shift
	 * @param inDeltaY y shift
	 */
	public void panMap(int inDeltaX, int inDeltaY)
	{
		_mapPosition.pan(inDeltaX, inDeltaY);
		_recalculate = true;
		repaint();
	}

	/**
	 * Create a DataPoint object from the given click coordinates
	 * @param inX x coordinate of click
	 * @param inY y coordinate of click
	 * @param inNewSegment true to start a new segment, false to continue
	 * @return DataPoint with given coordinates and no altitude, or null if values are invalid
	 */
	private DataPoint createPointFromClick(int inX, int inY, boolean inNewSegment)
	{
		double lat = MapUtils.getLatitudeFromY(_mapPosition.getYFromPixels(inY, getHeight()));
		double lon = MapUtils.getLongitudeFromX(_mapPosition.getXFromPixels(inX, getWidth()));
		Coordinate latitude = Latitude.make(lat);
		Coordinate longitude = Longitude.make(lon);
		if (latitude != null && longitude != null)
		{
			DataPoint point = new DataPoint(Latitude.make(lat), Longitude.make(lon));
			point.setSegmentStart(inNewSegment);
			return point;
		}
		return null;
	}

	/**
	 * Move a DataPoint object to the given mouse coordinates
	 * @param startX start x coordinate of mouse
	 * @param startY start y coordinate of mouse
	 * @param endX end x coordinate of mouse
	 * @param endY end y coordinate of mouse
	 */
	private void movePointToMouse(int startX, int startY, int endX, int endY )
	{
		double lat1 = MapUtils.getLatitudeFromY(_mapPosition.getYFromPixels(startY, getHeight()));
		double lon1 = MapUtils.getLongitudeFromX(_mapPosition.getXFromPixels(startX, getWidth()));
		double lat_delta = MapUtils.getLatitudeFromY(_mapPosition.getYFromPixels(endY, getHeight())) - lat1;
		double lon_delta = MapUtils.getLongitudeFromX(_mapPosition.getXFromPixels(endX, getWidth())) - lon1;

		DataPoint point = _trackInfo.getCurrentPoint();
		if (point == null) {
			return;
		}

		final String latitude = String.format("%.12f", point.getLatitude().getDouble() + lat_delta);
		final String longitude = String.format("%.12f", point.getLongitude().getDouble() + lon_delta);
		List<FieldEdit> edits = List.of(new FieldEdit(Field.LATITUDE, latitude),
			new FieldEdit(Field.LONGITUDE, longitude));
		int pointIndex = _app.getTrackInfo().getSelection().getCurrentPointIndex();
		EditPointCmd command = new EditPointCmd(pointIndex, edits);
		Describer undoDescriber = new Describer("undo.editpoint", "undo.editpoint.withname");
		command.setDescription(undoDescriber.getDescriptionWithNameOrNot(point.getWaypointName()));
		command.setConfirmText(I18nManager.getText("confirm.point.edit"));
		_app.execute(command);
	}


	/**
	 * @see javax.swing.JComponent#getMinimumSize()
	 */
	public Dimension getMinimumSize() {
		return new Dimension(512, 300);
	}

	/**
	 * @see javax.swing.JComponent#getPreferredSize()
	 */
	public Dimension getPreferredSize() {
		return getMinimumSize();
	}


	/**
	 * Respond to mouse click events
	 * @see java.awt.event.MouseListener#mouseClicked(java.awt.event.MouseEvent)
	 */
	public void mouseClicked(MouseEvent inE)
	{
		final boolean showMap = _app.getConfig().getConfigBoolean(Config.KEY_SHOW_MAP);
		final boolean hasPoints = _track != null && _track.getNumPoints() > 0;
		if (showMap || hasPoints)
		{
			// select point if it's a left-click
			if (!inE.isMetaDown())
			{
				if (inE.getClickCount() == 1)
				{
					// single left click
					if (_drawMode == DrawMode.DEFAULT && hasPoints)
					{
						int pointIndex = _clickedPoint;
						if (pointIndex == INDEX_UNKNOWN)
						{
							// index hasn't been calculated yet
							pointIndex = _track.getNearestPointIndex(
							 _mapPosition.getXFromPixels(inE.getX(), getWidth()),
							 _mapPosition.getYFromPixels(inE.getY(), getHeight()),
							 _mapPosition.getBoundsFromPixels(CLICK_SENSITIVITY), false);
						}
						// Extend selection for shift-click
						if (inE.isShiftDown()) {
							_trackInfo.extendSelection(pointIndex);
						}
						else {
							_trackInfo.selectPoint(pointIndex);
						}
					}
					else if (_drawMode == DrawMode.DRAW_POINTS_START)
					{
						DataPoint point = createPointFromClick(inE.getX(), inE.getY(), true);
						insertPoint(point, -1);
						_dragToX = inE.getX();
						_dragToY = inE.getY();
						_drawMode = DrawMode.DRAW_POINTS_CONT;
					}
					else if (_drawMode == DrawMode.DRAW_POINTS_CONT)
					{
						DataPoint point = createPointFromClick(inE.getX(), inE.getY(), false);
						insertPoint(point, -1); // append
					}
				}
				else if (inE.getClickCount() == 2)
				{
					// double click
					if (_drawMode == DrawMode.DEFAULT) {
						panMap(inE.getX() - getWidth()/2, inE.getY() - getHeight()/2);
						zoomIn();
					}
					else if (_drawMode == DrawMode.DRAW_POINTS_START || _drawMode == DrawMode.DRAW_POINTS_CONT) {
						_drawMode = DrawMode.DEFAULT;
					}
				}
			}
			else
			{
				// show the popup menu for right-clicks
				_popupMenuX = inE.getX();
				_popupMenuY = inE.getY();
				_popup.show(this, _popupMenuX, _popupMenuY);
			}
		}
		// Reset app mode
		_app.setCurrentMode(App.AppMode.NORMAL);
		if (_drawMode == DrawMode.MARK_RECTANGLE_INSIDE || _drawMode == DrawMode.MARK_RECTANGLE_OUTSIDE) {
			_drawMode = DrawMode.DEFAULT;
		}
	}

	/**
	 * Insert the given point into the track
	 * @param inPoint point to insert
	 * @param inInsertIndex index of insertion, or -1 for append
	 */
	private void insertPoint(DataPoint inPoint, int inInsertIndex)
	{
		if (inInsertIndex < -1 || inPoint == null) {
			return;
		}
		InsertPointCmd command = new InsertPointCmd(inPoint, inInsertIndex);
		command.setDescription(I18nManager.getText("undo.createpoint"));
		command.setConfirmText(I18nManager.getText("confirm.createpoint"));
		_app.execute(command);
	}


	/**
	 * Ignore mouse enter events
	 * @see java.awt.event.MouseListener#mouseEntered(java.awt.event.MouseEvent)
	 */
	public void mouseEntered(MouseEvent inE) {
		// ignore
	}

	/**
	 * Ignore mouse exited events
	 * @see java.awt.event.MouseListener#mouseExited(java.awt.event.MouseEvent)
	 */
	public void mouseExited(MouseEvent inE) {
		// ignore
	}

	/**
	 * React to mouse pressed events to initiate a point drag
	 * @see java.awt.event.MouseListener#mousePressed(java.awt.event.MouseEvent)
	 */
	public void mousePressed(MouseEvent inE)
	{
		_clickedPoint = INDEX_UNKNOWN;
		if (_track == null || _track.getNumPoints() <= 0) {
			return;
		}
		if (inE.isMetaDown()) {
			return; // right-press ignored
		}
		// Left mouse drag - check if point is near; if so select it for dragging
		if (_drawMode == DrawMode.DEFAULT)
		{
			/* Drag points if edit mode is enabled OR ALT is pressed */
			if (_editmodeCheckBox.isSelected() || inE.isAltDown() || inE.isAltGraphDown())
			{
				final double clickX = _mapPosition.getXFromPixels(inE.getX(), getWidth());
				final double clickY = _mapPosition.getYFromPixels(inE.getY(), getHeight());
				final double clickSens = _mapPosition.getBoundsFromPixels(CLICK_SENSITIVITY);
				_clickedPoint = _track.getNearestPointIndex(clickX, clickY, clickSens, false);

				if (_clickedPoint >= 0)
				{
					// TODO: maybe use another color of the cross or remove the cross while dragging???

					_trackInfo.selectPoint(_clickedPoint);
					if (_trackInfo.getCurrentPoint() != null)
					{
						_drawMode = DrawMode.DRAG_POINT;
						_dragFromX = _dragToX = inE.getX();
						_dragFromY = _dragToY = inE.getY();
					}
				}
				else
				{
					// Not a click on a point, so check half-way between two (connected) trackpoints
					int midpointIndex = _midpoints.getNearestPointIndex(clickX, clickY, clickSens);
					if (midpointIndex > 0)
					{
						_drawMode = DrawMode.CREATE_MIDPOINT;
						_clickedPoint = midpointIndex;
						_dragFromX = _dragToX = inE.getX();
						_dragFromY = _dragToY = inE.getY();
					}
				}
			}
		}
	}

	/**
	 * Respond to mouse released events
	 * @see java.awt.event.MouseListener#mouseReleased(java.awt.event.MouseEvent)
	 */
	public void mouseReleased(MouseEvent inE)
	{
		_recalculate = true;

		if (_drawMode == DrawMode.DRAG_POINT)
		{
			if (inE.isMetaDown()) {
				return;
			}
			if (Math.abs(_dragToX - _dragFromX) > 2
				|| Math.abs(_dragToY - _dragFromY) > 2)
			{
				movePointToMouse(_dragFromX, _dragFromY, _dragToX, _dragToY );
			}
			_drawMode = DrawMode.DEFAULT;
		}
		else if (_drawMode == DrawMode.CREATE_MIDPOINT)
		{
			if (inE.isMetaDown()) {
				return;
			}
			_drawMode = DrawMode.DEFAULT;
			insertPoint(createPointFromClick(_dragToX, _dragToY, false), _clickedPoint);
		}
		else if (_drawMode == DrawMode.ZOOM_RECT)
		{
			if (Math.abs(_dragToX - _dragFromX) > 20
			 && Math.abs(_dragToY - _dragFromY) > 20)
			{
				_mapPosition.zoomToPixels(_dragFromX, _dragToX, _dragFromY, _dragToY, getWidth(), getHeight());
				showZoomLevel();
			}
			_drawMode = DrawMode.DEFAULT;
		}
		else if (_drawMode == DrawMode.MARK_RECTANGLE_INSIDE || _drawMode == DrawMode.MARK_RECTANGLE_OUTSIDE)
		{
			if (inE.isMetaDown()) {
				return;
			}
			final boolean markInside = _drawMode == DrawMode.MARK_RECTANGLE_INSIDE;
			// Reset app mode
			_app.setCurrentMode(App.AppMode.NORMAL);
			_drawMode = DrawMode.DEFAULT;
			// Call a function to mark the points
			double lon1 = MapUtils.getLongitudeFromX(_mapPosition.getXFromPixels(_dragFromX, getWidth()));
			double lat1 = MapUtils.getLatitudeFromY(_mapPosition.getYFromPixels(_dragFromY, getHeight()));
			double lon2 = MapUtils.getLongitudeFromX(_mapPosition.getXFromPixels(_dragToX, getWidth()));
			double lat2 = MapUtils.getLatitudeFromY(_mapPosition.getYFromPixels(_dragToY, getHeight()));
			// Invalidate rectangle if pixel coords are (-1,-1)
			if (_dragFromX < 0 || _dragFromY < 0) {
				lon1 = lon2;
				lat1 = lat2;
			}
			MarkPointsInRectangleFunction marker = getMarkRectangleFunction(markInside);
			marker.setRectCoords(lon1, lat1, lon2, lat2);
			marker.begin();
		}
		_dragFromX = _dragFromY = -1;
		repaint();
	}

	/** Get the appropriate function from the library instead of creating a new one */
	private MarkPointsInRectangleFunction getMarkRectangleFunction(boolean inMarkInside)
	{
		GenericFunction function = inMarkInside ? FunctionLibrary.FUNCTION_MARK_INSIDE_RECTANGLE
			: FunctionLibrary.FUNCTION_MARK_OUTSIDE_RECTANGLE;
		return (MarkPointsInRectangleFunction) function;
	}


	/**
	 * Respond to mouse drag events
	 * @see java.awt.event.MouseMotionListener#mouseDragged(java.awt.event.MouseEvent)
	 */
	public void mouseDragged(MouseEvent inE)
	{
		// Note: One would expect inE.isMetaDown() to give information about whether this is a
		//       drag with the right mouse button or not - but since java 9 this is buggy,
		//       so we use the beautifully-named getModifiersEx() instead.
		//       And logically BUTTON3 refers to the secondary mouse button, not the tertiary one!
		final boolean isRightDrag = (inE.getModifiersEx() & InputEvent.BUTTON3_DOWN_MASK) > 0;
		if (isRightDrag)
		{
			// Right-click and drag - update rectangle
			_drawMode = DrawMode.ZOOM_RECT;
			if (_dragFromX == -1) {
				_dragFromX = inE.getX();
				_dragFromY = inE.getY();
			}
			_dragToX = inE.getX();
			_dragToY = inE.getY();
			repaint();
		}
		else
		{
			// Left mouse drag - decide whether to drag the point, drag the
			// marking rectangle or pan the map
			if (_drawMode == DrawMode.DRAG_POINT || _drawMode == DrawMode.CREATE_MIDPOINT)
			{
				// move point
				_dragToX = inE.getX();
				_dragToY = inE.getY();
				_recalculate = true;
				repaint();
			}
			else if (_drawMode == DrawMode.MARK_RECTANGLE_INSIDE || _drawMode == DrawMode.MARK_RECTANGLE_OUTSIDE)
			{
				// draw a rectangle for marking points
				if (_dragFromX == -1) {
					_dragFromX = inE.getX();
					_dragFromY = inE.getY();
				}
				_dragToX = inE.getX();
				_dragToY = inE.getY();
				repaint();
			}
			else
			{
				// regular left-drag pans map by appropriate amount
				if (_dragFromX != -1)
				{
					panMap(_dragFromX - inE.getX(), _dragFromY - inE.getY());
				}
				_dragFromX = _dragToX = inE.getX();
				_dragFromY = _dragToY = inE.getY();
			}
		}
	}

	/**
	 * Respond to mouse move events without button pressed
	 * @param inEvent ignored
	 */
	public void mouseMoved(MouseEvent inEvent)
	{
		boolean useCrosshairs = false;
		boolean useResize     = false;
		// Ignore unless we're drawing points
		if (_drawMode == DrawMode.DRAW_POINTS_CONT)
		{
			_dragToX = inEvent.getX();
			_dragToY = inEvent.getY();
			repaint();
		}
		else if (_drawMode == DrawMode.MARK_RECTANGLE_INSIDE || _drawMode == DrawMode.MARK_RECTANGLE_OUTSIDE) {
			useResize = true;
		}
		else if ((_editmodeCheckBox.isSelected() || inEvent.isAltDown() || inEvent.isAltGraphDown())
				&& _track.getNumPoints() > 0)
		{
			// Try to find a point or a midpoint at this location, and if there is one
			// then change the cursor to crosshairs
			final double clickX = _mapPosition.getXFromPixels(inEvent.getX(), getWidth());
			final double clickY = _mapPosition.getYFromPixels(inEvent.getY(), getHeight());
			final double clickSens = _mapPosition.getBoundsFromPixels(CLICK_SENSITIVITY);
			useCrosshairs = (_track.getNearestPointIndex(clickX, clickY, clickSens, false) >= 0
				|| _midpoints.getNearestPointIndex(clickX, clickY, clickSens) >= 0
			);
		}
		if (useCrosshairs && !isCursorSet()) {
			setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));
		}
		else if (useResize && !isCursorSet()) {
			setCursor(Cursor.getPredefinedCursor(Cursor.SE_RESIZE_CURSOR));
		}
		else if (!useCrosshairs && !useResize && isCursorSet()) {
			setCursor(null);
		}
	}

	/**
	 * Respond to status bar message from broker
	 * @param inMessage message, ignored
	 */
	public void actionCompleted(String inMessage) {
	}

	/**
	 * Respond to data updated message from broker
	 * @param inUpdateType type of update
	 */
	public void dataUpdated(int inUpdateType)
	{
		_recalculate = true;
		if ((inUpdateType & DataSubscriber.DATA_ADDED_OR_REMOVED) > 0) {
			_checkBounds = true;
		}
		if ((inUpdateType & DataSubscriber.MAPSERVER_CHANGED) > 0)
		{
			// Get the selected map source index and pass to tile manager
			Config config = _app.getConfig();
			_tileManager.setMapSource(config.getConfigInt(Config.KEY_MAPSOURCE_INDEX));
			_waypointIconDefinition = WaypointIcons.getDefinition(config, _app.getIconManager());
		}
		if ((inUpdateType & (DataSubscriber.DATA_ADDED_OR_REMOVED + DataSubscriber.DATA_EDITED)) > 0) {
			_midpoints.updateData(_track);
		}
		// See if rect mode has been activated
		final App.AppMode appMode = _app.getCurrentMode();
		if (appMode == App.AppMode.DRAWRECT_INSIDE || appMode == App.AppMode.DRAWRECT_OUTSIDE)
		{
			_drawMode = appMode == App.AppMode.DRAWRECT_INSIDE ? DrawMode.MARK_RECTANGLE_INSIDE : DrawMode.MARK_RECTANGLE_OUTSIDE;
			if (!isCursorSet()) {
				setCursor(Cursor.getPredefinedCursor(Cursor.SE_RESIZE_CURSOR));
			}
		}
		repaint();
		// grab focus for the key presses
		this.requestFocus();
	}

	/**
	 * Respond to key presses on the map canvas
	 * @param inE key event
	 */
	public void keyPressed(KeyEvent inE)
	{
		int code = inE.getKeyCode();
		int currPointIndex = _selection.getCurrentPointIndex();
		// Check for Ctrl key (for Linux/Win) or meta key (Clover key for Mac)
		if (inE.isControlDown() || inE.isMetaDown())
		{
			// Shift as well makes things faster
			final int pointIncrement = inE.isShiftDown()?3:1;
			// Check for arrow keys to zoom in and out
			if (code == KeyEvent.VK_UP)
				zoomIn();
			else if (code == KeyEvent.VK_DOWN)
				zoomOut();
			// Key nav for next/prev point
			else if (code == KeyEvent.VK_LEFT && currPointIndex > 0)
				_trackInfo.incrementPointIndex(-pointIncrement);
			else if (code == KeyEvent.VK_RIGHT)
				_trackInfo.incrementPointIndex(pointIncrement);
			else if (code == KeyEvent.VK_PAGE_UP)
				_trackInfo.selectPoint(Checker.getPreviousSegmentStart(
					_trackInfo.getTrack(), _trackInfo.getSelection().getCurrentPointIndex()));
			else if (code == KeyEvent.VK_PAGE_DOWN)
				_trackInfo.selectPoint(Checker.getNextSegmentStart(
					_trackInfo.getTrack(), _trackInfo.getSelection().getCurrentPointIndex()));
			// Check for home and end
			else if (code == KeyEvent.VK_HOME)
				_trackInfo.selectPoint(0);
			else if (code == KeyEvent.VK_END)
				_trackInfo.selectPoint(_trackInfo.getTrack().getNumPoints()-1);
		}
		else
		{
			// Check for arrow keys to pan
			int upwardsPan = 0;
			if (code == KeyEvent.VK_UP)
				upwardsPan = -PAN_DISTANCE;
			else if (code == KeyEvent.VK_DOWN)
				upwardsPan = PAN_DISTANCE;
			int rightwardsPan = 0;
			if (code == KeyEvent.VK_RIGHT)
				rightwardsPan = PAN_DISTANCE;
			else if (code == KeyEvent.VK_LEFT)
				rightwardsPan = -PAN_DISTANCE;
			panMap(rightwardsPan, upwardsPan);
			// Check for escape
			if (code == KeyEvent.VK_ESCAPE) {
				_drawMode = DrawMode.DEFAULT;
			}
			// Check for backspace key to delete current point (delete key already handled by menu)
			else if (code == KeyEvent.VK_BACK_SPACE && currPointIndex >= 0) {
				new DeleteCurrentPoint(_app).begin();
			}
			// Check for home and end (without Ctrl)
			else if (code == KeyEvent.VK_HOME && _trackInfo.getSelection().hasRangeSelected()) {
				_trackInfo.selectPoint(_trackInfo.getSelection().getStart());
			}
			else if (code == KeyEvent.VK_END && _trackInfo.getSelection().hasRangeSelected()) {
				_trackInfo.selectPoint(_trackInfo.getSelection().getEnd());
			}
		}
	}

	/**
	 * @param e key released event, ignored
	 */
	public void keyReleased(KeyEvent e) {
	}

	/**
	 * @param inE key typed event, ignored
	 */
	public void keyTyped(KeyEvent inE) {
	}

	/**
	 * @param inE mouse wheel event indicating scroll direction
	 */
	public void mouseWheelMoved(MouseWheelEvent inE)
	{
		final int clicks = inE.getWheelRotation();
		final int deltaX = (inE.getX() - getWidth()/2) / 2;
		final int deltaY = (inE.getY() - getHeight()/2) / 2;
		if (clicks < 0) {
			panMap(deltaX, deltaY);
			zoomIn();
		}
		else if (clicks > 0) {
			panMap(-deltaX, -deltaY);
			zoomOut();
		}
	}

	private int getScaledHeight() {
		final int normalHeight = super.getHeight();
		return (int) (normalHeight * _lastScale);
	}

	private int getScaledWidth() {
		final int normalWidth = super.getWidth();
		return (int) (normalWidth * _lastScale);
	}

	public double getMinYValue() {return _mapPosition.getYFromPixels(getHeight(), getHeight());}
	public double getMaxYValue() {return _mapPosition.getYFromPixels(0, getHeight());}
	public double getMinXValue() {return _mapPosition.getXFromPixels(0, getWidth());}
	public double getMaxXValue() {return _mapPosition.getXFromPixels(getWidth(), getWidth());}
}
