slash.navigation.mapview.browser.BrowserMapView.java Source code

Java tutorial

Introduction

Here is the source code for slash.navigation.mapview.browser.BrowserMapView.java

Source

/*
This file is part of RouteConverter.
    
RouteConverter is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
    
RouteConverter is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.
    
You should have received a copy of the GNU General Public License
along with RouteConverter; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
    
Copyright (C) 2007 Christian Pesch. All Rights Reserved.
*/

package slash.navigation.mapview.browser;

import slash.common.io.TokenResolver;
import slash.common.type.CompactCalendar;
import slash.navigation.base.BaseNavigationFormat;
import slash.navigation.base.BaseNavigationPosition;
import slash.navigation.base.BaseRoute;
import slash.navigation.base.RouteCharacteristics;
import slash.navigation.base.WaypointType;
import slash.navigation.base.Wgs84Position;
import slash.navigation.columbus.ColumbusGpsBinaryFormat;
import slash.navigation.columbus.ColumbusGpsFormat;
import slash.navigation.common.BoundingBox;
import slash.navigation.common.DistanceAndTime;
import slash.navigation.common.NavigationPosition;
import slash.navigation.common.PositionPair;
import slash.navigation.common.SimpleNavigationPosition;
import slash.navigation.converter.gui.models.BooleanModel;
import slash.navigation.converter.gui.models.CharacteristicsModel;
import slash.navigation.converter.gui.models.ColorModel;
import slash.navigation.converter.gui.models.FixMapMode;
import slash.navigation.converter.gui.models.FixMapModeModel;
import slash.navigation.converter.gui.models.GoogleMapsServerModel;
import slash.navigation.converter.gui.models.PositionColumnValues;
import slash.navigation.converter.gui.models.PositionsModel;
import slash.navigation.converter.gui.models.PositionsSelectionModel;
import slash.navigation.converter.gui.models.UnitSystemModel;
import slash.navigation.gui.Application;
import slash.navigation.mapview.AbstractMapViewListener;
import slash.navigation.mapview.MapView;
import slash.navigation.mapview.MapViewCallback;
import slash.navigation.mapview.MapViewListener;
import slash.navigation.mapview.tileserver.TileServerService;
import slash.navigation.mapview.tileserver.binding.CopyrightType;
import slash.navigation.mapview.tileserver.binding.TileServerType;
import slash.navigation.nmn.NavigatingPoiWarnerFormat;

import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.ListDataEvent;
import javax.swing.event.ListDataListener;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.xml.bind.JAXBException;
import java.awt.*;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.ResourceBundle;
import java.util.StringTokenizer;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.logging.Logger;
import java.util.prefs.Preferences;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.lang.Boolean.parseBoolean;
import static java.lang.Character.isLetterOrDigit;
import static java.lang.Character.isWhitespace;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.System.currentTimeMillis;
import static java.lang.Thread.sleep;
import static java.util.Arrays.asList;
import static java.util.Calendar.SECOND;
import static java.util.Collections.sort;
import static java.util.concurrent.Executors.newCachedThreadPool;
import static java.util.concurrent.Executors.newSingleThreadExecutor;
import static javax.swing.JOptionPane.ERROR_MESSAGE;
import static javax.swing.JOptionPane.showMessageDialog;
import static javax.swing.SwingUtilities.invokeLater;
import static javax.swing.event.ListDataEvent.CONTENTS_CHANGED;
import static javax.swing.event.TableModelEvent.ALL_COLUMNS;
import static javax.swing.event.TableModelEvent.DELETE;
import static javax.swing.event.TableModelEvent.INSERT;
import static javax.swing.event.TableModelEvent.UPDATE;
import static org.apache.commons.lang.StringEscapeUtils.escapeHtml;
import static slash.common.helpers.ExceptionHelper.getLocalizedMessage;
import static slash.common.helpers.ThreadHelper.safeJoin;
import static slash.common.io.Externalization.extractFile;
import static slash.common.io.Transfer.UTF8_ENCODING;
import static slash.common.io.Transfer.ceiling;
import static slash.common.io.Transfer.decodeUri;
import static slash.common.io.Transfer.isEmpty;
import static slash.common.io.Transfer.parseDouble;
import static slash.common.io.Transfer.parseInt;
import static slash.common.io.Transfer.parseInteger;
import static slash.common.io.Transfer.parseLong;
import static slash.common.io.Transfer.toDouble;
import static slash.common.io.Transfer.trim;
import static slash.common.io.Transfer.trimLineFeeds;
import static slash.common.type.CompactCalendar.fromCalendar;
import static slash.common.type.HexadecimalNumber.encodeByte;
import static slash.navigation.base.RouteCharacteristics.Route;
import static slash.navigation.base.RouteCharacteristics.Track;
import static slash.navigation.base.RouteCharacteristics.Waypoints;
import static slash.navigation.base.WaypointType.End;
import static slash.navigation.base.WaypointType.Start;
import static slash.navigation.base.WaypointType.Waypoint;
import static slash.navigation.converter.gui.models.CharacteristicsModel.IGNORE;
import static slash.navigation.converter.gui.models.FixMapMode.Automatic;
import static slash.navigation.converter.gui.models.FixMapMode.Yes;
import static slash.navigation.converter.gui.models.PositionColumns.DATE_TIME_COLUMN_INDEX;
import static slash.navigation.converter.gui.models.PositionColumns.DESCRIPTION_COLUMN_INDEX;
import static slash.navigation.converter.gui.models.PositionColumns.ELEVATION_COLUMN_INDEX;
import static slash.navigation.converter.gui.models.PositionColumns.LATITUDE_COLUMN_INDEX;
import static slash.navigation.converter.gui.models.PositionColumns.LONGITUDE_COLUMN_INDEX;
import static slash.navigation.googlemaps.GoogleMapsAPIKey.getAPIKey;
import static slash.navigation.gui.events.Range.asRange;
import static slash.navigation.gui.helpers.JTableHelper.isFirstToLastRow;
import static slash.navigation.mapview.MapViewConstants.ROUTE_LINE_WIDTH_PREFERENCE;
import static slash.navigation.mapview.MapViewConstants.TRACK_LINE_WIDTH_PREFERENCE;
import static slash.navigation.mapview.browser.TransformUtil.delta;
import static slash.navigation.mapview.browser.TransformUtil.isPositionInChina;

/**
 * Base implementation for a browser-based map view.
 *
 * @author Christian Pesch
 */

public abstract class BrowserMapView implements MapView {
    protected static final Preferences preferences = Preferences.userNodeForPackage(BrowserMapView.class);
    protected static final Logger log = Logger.getLogger(MapView.class.getName());
    private static final String RESOURCES_PACKAGE = "slash/navigation/mapview/browser/";

    private static final String MAP_TYPE_PREFERENCE = "mapType";
    protected static final String DEBUG_PREFERENCE = "debug";
    protected static final String BROWSER_SCALE_FACTOR_PREFERENCE = "browserScaleFactor";
    private static final String CLEAN_ELEVATION_ON_MOVE_PREFERENCE = "cleanElevationOnMove";
    private static final String COMPLEMENT_ELEVATION_ON_MOVE_PREFERENCE = "complementElevationOnMove";
    private static final String CLEAN_TIME_ON_MOVE_PREFERENCE = "cleanTimeOnMove";
    private static final String COMPLEMENT_TIME_ON_MOVE_PREFERENCE = "complementTimeOnMove";
    private static final String MOVE_COMPLETE_SELECTION_PREFERENCE = "moveCompleteSelection";
    private static final String CENTER_LATITUDE_PREFERENCE = "centerLatitude";
    private static final String CENTER_LONGITUDE_PREFERENCE = "centerLongitude";
    private static final String CENTER_ZOOM_PREFERENCE = "centerZoom";

    private PositionsModel positionsModel;
    private PositionsSelectionModel positionsSelectionModel;
    private CharacteristicsModel characteristicsModel;
    private List<NavigationPosition> lastSelectedPositions = new ArrayList<>();
    private int[] selectedPositionIndices = new int[0];
    private List<NavigationPosition> selectedPositions = new ArrayList<>();
    private int lastZoom = -1;

    private ServerSocket callbackListenerServerSocket;
    private Thread positionListUpdater, selectionUpdater, callbackListener, callbackPoller;

    protected final Object notificationMutex = new Object();
    protected boolean initialized = false;
    private boolean running = true, haveToInitializeMapOnFirstStart = true,
            haveToRepaintSelectionImmediately = false, haveToRepaintRouteImmediately = false,
            haveToRecenterMap = false, haveToUpdateRoute = false, haveToReplaceRoute = false,
            haveToRepaintSelection = false, ignoreNextZoomCallback = false;

    private BooleanModel showAllPositionsAfterLoading;
    private BooleanModel recenterAfterZooming;
    private BooleanModel showCoordinates;
    private BooleanModel showWaypointDescription;
    private FixMapModeModel fixMapModeModel;
    private ColorModel routeColorModel;
    private ColorModel trackColorModel;
    private UnitSystemModel unitSystemModel;
    private GoogleMapsServerModel googleMapsServerModel;

    private PositionsModelListener positionsModelListener = new PositionsModelListener();
    private CharacteristicsModelListener characteristicsModelListener = new CharacteristicsModelListener();
    private MapViewCallbackListener mapViewCallbackListener = new MapViewCallbackListener();
    private ShowCoordinatesListener showCoordinatesListener = new ShowCoordinatesListener();
    private ShowWaypointDescriptionListener showWaypointDescriptionListener = new ShowWaypointDescriptionListener();
    private RepaintPositionListListener repaintPositionListListener = new RepaintPositionListListener();
    private UnitSystemListener unitSystemListener = new UnitSystemListener();
    private GoogleMapsServerListener googleMapsServerListener = new GoogleMapsServerListener();

    private String routeUpdateReason = "?", selectionUpdateReason = "?";
    protected MapViewCallback mapViewCallback;
    private PositionReducer positionReducer;
    private final ExecutorService executor = newCachedThreadPool();
    private int overQueryLimitCount = 0, zeroResultsCount = 0;

    // initialization

    public void initialize(PositionsModel positionsModel, PositionsSelectionModel positionsSelectionModel,
            CharacteristicsModel characteristicsModel, MapViewCallback mapViewCallback,
            BooleanModel showAllPositionsAfterLoading, BooleanModel recenterAfterZooming,
            BooleanModel showCoordinates, BooleanModel showWaypointDescription, FixMapModeModel fixMapModeModel,
            ColorModel aRouteColorModel, ColorModel aTrackColorModel, UnitSystemModel unitSystemModel,
            GoogleMapsServerModel googleMapsServerModel) {
        this.positionsModel = positionsModel;
        this.positionsSelectionModel = positionsSelectionModel;
        this.characteristicsModel = characteristicsModel;
        this.mapViewCallback = mapViewCallback;
        this.mapViewCallback = mapViewCallback;
        this.showAllPositionsAfterLoading = showAllPositionsAfterLoading;
        this.recenterAfterZooming = recenterAfterZooming;
        this.showCoordinates = showCoordinates;
        this.showWaypointDescription = showWaypointDescription;
        this.fixMapModeModel = fixMapModeModel;
        this.routeColorModel = aRouteColorModel;
        this.trackColorModel = aTrackColorModel;
        this.unitSystemModel = unitSystemModel;
        this.googleMapsServerModel = googleMapsServerModel;

        initializeBrowser();

        positionsModel.addTableModelListener(positionsModelListener);
        characteristicsModel.addListDataListener(characteristicsModelListener);
        mapViewCallback.addRoutingServiceChangeListener(mapViewCallbackListener);
        showCoordinates.addChangeListener(showCoordinatesListener);
        showWaypointDescription.addChangeListener(showWaypointDescriptionListener);
        fixMapModeModel.addChangeListener(repaintPositionListListener);
        routeColorModel.addChangeListener(repaintPositionListListener);
        trackColorModel.addChangeListener(repaintPositionListListener);
        unitSystemModel.addChangeListener(unitSystemListener);
        googleMapsServerModel.addChangeListener(googleMapsServerListener);

        positionReducer = new PositionReducer(new PositionReducer.Callback() {
            public int getZoom() {
                return BrowserMapView.this.getZoom();
            }

            public NavigationPosition getNorthEastBounds() {
                return BrowserMapView.this.getNorthEastBounds();
            }

            public NavigationPosition getSouthWestBounds() {
                return BrowserMapView.this.getSouthWestBounds();
            }
        });
    }

    protected abstract void initializeBrowser();

    protected abstract void initializeWebPage();

    protected double getBrowserScaleFactor() {
        return (double) preferences.getInt(BROWSER_SCALE_FACTOR_PREFERENCE, 100) / 100.0;
    }

    protected String getGoogleMapsServerApiUrl() {
        return googleMapsServerModel.getGoogleMapsServer().getApiUrl();
    }

    protected String prepareWebPage() throws IOException {
        final String language = Locale.getDefault().getLanguage().toLowerCase();
        final String country = Locale.getDefault().getCountry().toLowerCase();
        final TileServerService tileServerService = loadAllTileServers(mapViewCallback.getTileServersDirectory());
        File html = extractFile(RESOURCES_PACKAGE + "routeconverter.html", country, new TokenResolver() {
            public String resolveToken(String tokenName) {
                if (tokenName.equals("language"))
                    return language;
                if (tokenName.equals("country"))
                    return country;
                if (tokenName.equals("mapserverapiurl"))
                    return getGoogleMapsServerApiUrl();
                if (tokenName.equals("mapserverfileurl"))
                    return googleMapsServerModel.getGoogleMapsServer().getFileUrl();
                if (tokenName.equals("maptype"))
                    return getMapType();
                if (tokenName.equals("mapsapikey"))
                    return getAPIKey("map");
                if (tokenName.equals("tileservers1"))
                    return registerTileServers(tileServerService, true);
                if (tokenName.equals("tileservers2"))
                    return registerTileServers(tileServerService, false);
                if (tokenName.equals("menuItems"))
                    return registerMenuItems();
                return tokenName;
            }
        });
        if (html == null)
            throw new IllegalArgumentException("Cannot extract routeconverter.html");

        extractFile(RESOURCES_PACKAGE + "jquery.min.js");
        extractFile(RESOURCES_PACKAGE + "contextmenu.js");
        extractFile(RESOURCES_PACKAGE + "keydragzoom.js");
        extractFile(RESOURCES_PACKAGE + "label.js");
        extractFile(RESOURCES_PACKAGE + "latlngcontrol.js");

        return html.toURI().toURL().toExternalForm();
    }

    protected void tryToInitialize(int count, long start) {
        boolean initialized = getComponent() != null && isMapInitialized();
        synchronized (INITIALIZED_LOCK) {
            this.initialized = initialized;
        }
        log.fine("Initialized map: " + initialized);

        if (isInitialized()) {
            runBrowserInteractionCallbacksAndTests(start);
        } else {
            long end = currentTimeMillis();
            int timeout = count++ * 100;
            if (timeout > 3000)
                timeout = 3000;
            log.info("Failed to initialize map since " + (end - start) + " ms, sleeping for " + timeout + " ms");

            try {
                sleep(timeout);
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            tryToInitialize(count, start);
        }
    }

    protected void runBrowserInteractionCallbacksAndTests(long start) {
        long end = currentTimeMillis();
        log.fine("Starting browser interaction, callbacks and tests after " + (end - start) + " ms");
        initializeAfterLoading();
        initializeBrowserInteraction();
        initializeCallbackListener();
        checkLocalhostResolution();
        checkCallback();
        setDegreeFormat();
        setShowCoordinates();
        end = currentTimeMillis();
        log.fine("Browser interaction is running after " + (end - start) + " ms");
    }

    protected abstract boolean isMapInitialized();

    protected void initializeAfterLoading() {
        resize();
        update(true, false);
    }

    private Throwable initializationCause = null;

    public Throwable getInitializationCause() {
        return initializationCause;
    }

    protected void setInitializationCause(Throwable initializationCause) {
        this.initializationCause = initializationCause;
    }

    private static final Object INITIALIZED_LOCK = new Object();

    public boolean isInitialized() {
        synchronized (INITIALIZED_LOCK) {
            return initialized;
        }
    }

    public boolean isDownload() {
        return false;
    }

    protected void initializeBrowserInteraction() {
        getComponent().addComponentListener(new ComponentListener() {
            public void componentResized(ComponentEvent e) {
                resize();
            }

            public void componentMoved(ComponentEvent e) {
            }

            public void componentShown(ComponentEvent e) {
            }

            public void componentHidden(ComponentEvent e) {
            }
        });

        positionListUpdater = new Thread(new Runnable() {
            @SuppressWarnings("unchecked")
            public void run() {
                long lastTime = 0;
                boolean recenter;
                while (true) {
                    List<NavigationPosition> copiedPositions;
                    synchronized (notificationMutex) {
                        try {
                            notificationMutex.wait(1000);
                        } catch (InterruptedException e) {
                            // ignore this
                        }

                        if (!running)
                            return;
                        if (!hasPositions())
                            continue;
                        if (!isVisible())
                            continue;

                        /*
                           Update conditions:
                            
                           - new route was loaded
                         - clear cache
                         - center map
                         - set zoom level according to route bounds
                         - repaint immediately
                           - user has moved position
                         - clear cache
                         - stay on current zoom level
                         - center map to position
                         - repaint
                           - user has removed position
                         - clear cache
                         - stay on current zoom level
                         - repaint
                           - user has zoomed map
                         - repaint if zooming into the map as it reveals more details
                           - user has moved map
                         - repaint if moved
                         */
                        long currentTime = currentTimeMillis();
                        if (haveToRepaintRouteImmediately || haveToReplaceRoute
                                || (haveToUpdateRoute && (currentTime - lastTime > 5 * 1000))) {
                            log.info("Woke up to update route: " + routeUpdateReason + " haveToUpdateRoute:"
                                    + haveToUpdateRoute + " haveToReplaceRoute:" + haveToReplaceRoute
                                    + " haveToRepaintRouteImmediately:" + haveToRepaintRouteImmediately);
                            copiedPositions = new ArrayList<>(positionsModel.getRoute().getPositions());
                            recenter = haveToReplaceRoute;
                            haveToUpdateRoute = false;
                            haveToReplaceRoute = false;
                            haveToRepaintRouteImmediately = false;
                        } else
                            continue;
                    }

                    setCenterOfMap(copiedPositions, recenter);
                    RouteCharacteristics characteristics = positionsModel.getRoute().getCharacteristics();
                    List<NavigationPosition> render = positionReducer.reducePositions(copiedPositions,
                            characteristics, showWaypointDescription.getBoolean());
                    switch (characteristics) {
                    case Route:
                        addDirectionsToMap(render);
                        break;
                    case Track:
                        addPolylinesToMap(render, copiedPositions);
                        break;
                    case Waypoints:
                        addMarkersToMap(render);
                        break;
                    default:
                        throw new IllegalArgumentException(
                                "RouteCharacteristics " + characteristics + " is not supported");
                    }
                    log.info("Position list updated for " + render.size() + " positions of type " + characteristics
                            + ", reason: " + routeUpdateReason + ", recentering: " + recenter);
                    lastTime = currentTimeMillis();
                }
            }
        }, "MapViewPositionListUpdater");
        positionListUpdater.start();

        selectionUpdater = new Thread(new Runnable() {
            @SuppressWarnings("unchecked")
            public void run() {
                long lastTime = 0;
                while (true) {
                    int[] copiedSelectedPositionIndices;
                    List<NavigationPosition> copiedPositions;
                    boolean recenter;
                    synchronized (notificationMutex) {
                        try {
                            notificationMutex.wait(250);
                        } catch (InterruptedException e) {
                            // ignore this
                        }

                        if (!running)
                            return;
                        if (!hasPositions())
                            continue;
                        if (!isVisible())
                            continue;

                        long currentTime = currentTimeMillis();
                        if (haveToRecenterMap || haveToRepaintSelectionImmediately
                                || (haveToRepaintSelection && (currentTime - lastTime > 500))) {
                            log.fine("Woke up to update selected positions: " + selectionUpdateReason
                                    + " haveToRepaintSelection: " + haveToRepaintSelection
                                    + " haveToRepaintSelectionImmediately: " + haveToRepaintSelectionImmediately
                                    + " haveToRecenterMap: " + haveToRecenterMap);
                            recenter = haveToRecenterMap;
                            haveToRecenterMap = false;
                            haveToRepaintSelectionImmediately = false;
                            haveToRepaintSelection = false;
                            copiedSelectedPositionIndices = new int[selectedPositionIndices.length];
                            System.arraycopy(selectedPositionIndices, 0, copiedSelectedPositionIndices, 0,
                                    copiedSelectedPositionIndices.length);
                            copiedPositions = new ArrayList<>(positionsModel.getRoute().getPositions());
                        } else
                            continue;
                    }

                    List<NavigationPosition> render = new ArrayList<>(positionReducer
                            .reduceSelectedPositions(copiedPositions, copiedSelectedPositionIndices));
                    render.addAll(selectedPositions);
                    NavigationPosition centerPosition = render.size() > 0 ? new BoundingBox(render).getCenter()
                            : null;
                    selectPositions(render, recenter ? centerPosition : null);
                    log.info("Selected positions updated for " + render.size() + " positions , reason: "
                            + selectionUpdateReason + ", recentering: " + recenter + " to: " + centerPosition);
                    lastTime = currentTimeMillis();
                }
            }
        }, "MapViewSelectionUpdater");
        selectionUpdater.start();
    }

    private ServerSocket createCallbackListenerServerSocket() {
        try {
            ServerSocket serverSocket = new ServerSocket(0, 0,
                    InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 }));
            serverSocket.setSoTimeout(1000);
            int port = serverSocket.getLocalPort();
            log.info("Map listens on port " + port + " for callbacks");
            setCallbackListenerPort(port);
            return serverSocket;
        } catch (IOException e) {
            log.severe("Cannot open callback listener socket: " + e);
            return null;
        }
    }

    protected void initializeCallbackListener() {
        callbackListenerServerSocket = createCallbackListenerServerSocket();
        if (callbackListenerServerSocket == null)
            return;

        callbackListener = new Thread(new Runnable() {
            public void run() {
                while (true) {
                    synchronized (notificationMutex) {
                        if (!running) {
                            return;
                        }
                    }

                    try {
                        final Socket socket = callbackListenerServerSocket.accept();
                        executor.execute(new Runnable() {
                            public void run() {
                                try {
                                    processStream(socket);
                                } catch (IOException e) {
                                    e.printStackTrace();
                                    log.severe("Cannot process stream from callback listener socket: " + e);
                                }
                            }
                        });
                    } catch (SocketTimeoutException e) {
                        // intentionally left empty
                    } catch (IOException e) {
                        synchronized (notificationMutex) {
                            //noinspection ConstantConditions
                            if (running) {
                                log.severe("Cannot accept callback listener socket: " + e);
                            }
                        }
                    }
                }
            }
        }, "MapViewCallbackListener");
        callbackListener.start();
    }

    protected void initializeCallbackPoller() {
        callbackPoller = new Thread(new Runnable() {
            public void run() {
                while (true) {
                    synchronized (notificationMutex) {
                        if (!running) {
                            return;
                        }
                    }

                    String callbacks = trim(getCallbacks());
                    if (callbacks != null) {
                        String[] lines = callbacks.split("--");
                        for (String line : lines) {
                            processCallback(line);
                        }
                    }

                    try {
                        sleep(250);
                    } catch (InterruptedException e) {
                        // intentionally left empty
                    }
                }
            }
        }, "MapViewCallbackPoller");
        callbackPoller.start();
    }

    protected void checkLocalhostResolution() {
        try {
            InetAddress localhost = InetAddress.getByName("localhost");
            log.info("localhost is resolved to: " + localhost);
            String localhostName = localhost.getHostAddress();
            log.info("IP of localhost is: " + localhostName);
            if (!localhostName.equals("127.0.0.1"))
                throw new Exception("localhost does not resolve to 127.0.0.1");

            InetAddress ip = InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 });
            log.info("127.0.0.1 is resolved to: " + ip);
            String ipName = localhost.getHostName();
            log.info("Name of 127.0.0.1 is: " + ipName);
            if (!ipName.equals("localhost"))
                throw new Exception("127.0.0.1 does not resolve to localhost");
        } catch (Exception e) {
            final String message = "Probably faulty network setup: " + getLocalizedMessage(e)
                    + ".\nPlease check your network settings.";
            log.severe(message);
            invokeLater(new Runnable() {
                public void run() {
                    showMessageDialog(getComponent(), message, "Error", ERROR_MESSAGE);
                }
            });
        }
    }

    protected void checkCallback() {
        final Boolean[] receivedCallback = new Boolean[1];
        receivedCallback[0] = false;

        final MapViewListener callbackWaiter = new AbstractMapViewListener() {
            public void receivedCallback(int port) {
                synchronized (receivedCallback) {
                    receivedCallback[0] = true;
                    receivedCallback.notifyAll();
                }
            }
        };

        executor.execute(new Runnable() {
            public void run() {
                addMapViewListener(callbackWaiter);
                try {
                    executeScript("checkCallbackListenerPort();");

                    long start = currentTimeMillis();
                    while (true) {
                        synchronized (receivedCallback) {
                            if (receivedCallback[0]) {
                                long end = currentTimeMillis();
                                log.info("Received callback from browser after " + (end - start) + " milliseconds");
                                break;
                            }
                        }

                        if (start + 5000 < currentTimeMillis())
                            break;

                        try {
                            sleep(50);
                        } catch (InterruptedException e) {
                            // intentionally left empty
                        }
                    }

                    synchronized (receivedCallback) {
                        if (!receivedCallback[0]) {
                            setCallbackListenerPort(-1);
                            initializeCallbackPoller();
                            log.warning("Switched from callback to polling the browser");
                        }
                    }
                } finally {
                    removeMapViewListener(callbackWaiter);
                }
            }
        });
    }

    // tile servers

    private static final List<String> GOOGLE_MAP_TYPES = asList("ROADMAP", "SATELLITE", "HYBRID", "TERRAIN");

    private String getMapType() {
        return preferences.get(MAP_TYPE_PREFERENCE, "google.maps.MapTypeId.ROADMAP");
    }

    private boolean isGoogleFixMap() {
        String mapType = getMapType();
        return mapType != null && GOOGLE_MAP_TYPES.contains(mapType.toUpperCase());
    }

    private static final String DOT_XML = ".xml";

    private TileServerService loadAllTileServers(java.io.File directory) {
        TileServerService result = new TileServerService();
        java.io.File[] files = directory.listFiles(new FilenameFilter() {
            public boolean accept(java.io.File dir, String name) {
                return name.endsWith(DOT_XML);
            }
        });

        if (files != null) {
            for (File file : files) {
                try {
                    try (InputStream inputStream = new FileInputStream(file)) {
                        result.load(inputStream);
                    }
                } catch (IOException | JAXBException e) {
                    log.severe(
                            "Could not parse tile server definitions from " + file + ": " + getLocalizedMessage(e));
                }
            }
        }
        return result;
    }

    private String registerTileServers(TileServerService tileServerService, boolean register) {
        StringBuilder buffer = new StringBuilder();

        if (register) {
            for (String tileServerId : GOOGLE_MAP_TYPES)
                buffer.append("mapTypeIds.push(google.maps.MapTypeId.").append(tileServerId).append("); ")
                        .append("mapCopyrights[google.maps.MapTypeId.").append(tileServerId)
                        .append("] = \"Google\";\n");
        }

        for (TileServerType tileServer : tileServerService.getTileServers()) {
            if (tileServer.getActive() != null && !tileServer.getActive())
                continue;

            if (register) {
                CopyrightType copyrightType = tileServer.getCopyright();
                buffer.append("mapTypeIds.push(\"").append(tileServer.getId()).append("\"); ")
                        .append("mapCopyrights[\"").append(tileServer.getId()).append("\"] = \"")
                        .append(copyrightType != null ? copyrightType.value() : "unknown").append("\";\n");
            } else
                buffer.append("map.mapTypes.set(\"").append(tileServer.getId())
                        .append("\", new google.maps.ImageMapType({\n")
                        .append("  getTileUrl: function(coordinates, zoom) {\n").append("    return ")
                        .append(trim(trimLineFeeds(tileServer.getValue()))).append(";\n").append("  },\n")
                        .append("  tileSize: DEFAULT_TILE_SIZE,\n").append("  minZoom: ")
                        .append(tileServer.getMinZoom()).append(",\n").append("  maxZoom: ")
                        .append(tileServer.getMaxZoom()).append(",\n").append("  alt: \"")
                        .append(tileServer.getName()).append("\",\n").append("  name: \"")
                        .append(tileServer.getId()).append("\"\n").append("}));\n");
        }

        return buffer.toString();
    }

    private static final String[] MENU_ITEM_KEYS = new String[] { "center-here-action", "delete-action",
            "new-position-action", "select-position-action", "zoom-in-action", "zoom-out-action" };

    private String registerMenuItems() {
        StringBuilder buffer = new StringBuilder();

        ResourceBundle bundle = Application.getInstance().getContext().getBundle();
        for (String menuItemKey : MENU_ITEM_KEYS)
            buffer.append("menuItems[\"").append(menuItemKey).append("\"] = ").append("\"")
                    .append(escapeHtml(bundle.getString(menuItemKey))).append("\";\n");

        return buffer.toString();
    }

    private boolean isColumbusTrack() {
        BaseNavigationFormat format = positionsModel.getRoute().getFormat();
        return format instanceof ColumbusGpsFormat || format instanceof ColumbusGpsBinaryFormat;
    }

    // resizing

    private boolean hasBeenResizedToInvisible = false;

    public void resize() {
        if (!isInitialized() || !getComponent().isShowing())
            return;

        new Thread(new Runnable() {
            public void run() {
                synchronized (notificationMutex) {
                    // if map is not visible remember to update and resize it again
                    // once the map becomes visible again
                    if (!isVisible()) {
                        hasBeenResizedToInvisible = true;
                    } else if (hasBeenResizedToInvisible) {
                        hasBeenResizedToInvisible = false;
                        update(true, false);
                    }
                    resizeMap();
                }
            }
        }, "BrowserResizer").start();
    }

    private int lastWidth = -1, lastHeight = -1;

    private void resizeMap() {
        synchronized (notificationMutex) {
            double browserScaleFactor = getBrowserScaleFactor();
            int width = (int) max(getComponent().getWidth() / browserScaleFactor, 0.0);
            int height = (int) max(getComponent().getHeight() / browserScaleFactor, 0.0);
            if (width != lastWidth || height != lastHeight) {
                executeScript("resize(" + width + "," + height + ");");
            }
            lastWidth = width;
            lastHeight = height;
        }
    }

    // disposal

    public void dispose() {
        if (positionsModel != null) {
            positionsModel.removeTableModelListener(positionsModelListener);
            characteristicsModel.removeListDataListener(characteristicsModelListener);
            mapViewCallback.removeRoutingServiceChangeListener(mapViewCallbackListener);
            showCoordinates.removeChangeListener(showCoordinatesListener);
            showWaypointDescription.removeChangeListener(showWaypointDescriptionListener);
            fixMapModeModel.removeChangeListener(repaintPositionListListener);
            routeColorModel.removeChangeListener(repaintPositionListListener);
            trackColorModel.removeChangeListener(repaintPositionListListener);
            unitSystemModel.removeChangeListener(unitSystemListener);
            googleMapsServerModel.removeChangeListener(googleMapsServerListener);
        }

        long start = currentTimeMillis();
        synchronized (notificationMutex) {
            running = false;
            notificationMutex.notifyAll();
        }

        if (selectionUpdater != null) {
            try {
                safeJoin(selectionUpdater);
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            long end = currentTimeMillis();
            log.info("PositionUpdater stopped after " + (end - start) + " ms");
        }

        if (positionListUpdater != null) {
            try {
                safeJoin(positionListUpdater);
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            long end = currentTimeMillis();
            log.info("RouteUpdater stopped after " + (end - start) + " ms");
        }

        if (callbackListenerServerSocket != null) {
            try {
                callbackListenerServerSocket.close();
            } catch (IOException e) {
                log.warning("Cannot close callback listener socket:" + e);
            }
            long end = currentTimeMillis();
            log.info("CallbackListenerSocket stopped after " + (end - start) + " ms");
        }

        if (callbackListener != null) {
            try {
                safeJoin(callbackListener);
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            long end = currentTimeMillis();
            log.info("CallbackListener stopped after " + (end - start) + " ms");
        }

        if (callbackPoller != null && callbackPoller.isAlive()) {
            try {
                safeJoin(callbackPoller);
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            long end = currentTimeMillis();
            log.info("CallbackPoller stopped after " + (end - start) + " ms");
        }

        executor.shutdownNow();
        insertWaypointsExecutor.shutdownNow();
        long end = currentTimeMillis();
        log.info("Executors stopped after " + (end - start) + " ms");
    }

    // getter and setter

    protected boolean isVisible() {
        return getComponent().getWidth() > 0;
    }

    private boolean hasPositions() {
        synchronized (notificationMutex) {
            return isInitialized() && positionsModel.getRoute().getPositions() != null;
        }
    }

    private void setCallbackListenerPort(int callbackListenerPort) {
        synchronized (notificationMutex) {
            executeScript("setCallbackListenerPort(" + callbackListenerPort + ")");
        }
    }

    public void setSelectedPositions(int[] selectedPositions, boolean replaceSelection) {
        synchronized (notificationMutex) {
            if (replaceSelection)
                this.selectedPositionIndices = selectedPositions;
            else {
                int[] indices = new int[selectedPositionIndices.length + selectedPositions.length];
                System.arraycopy(selectedPositionIndices, 0, indices, 0, selectedPositionIndices.length);
                System.arraycopy(selectedPositions, 0, indices, selectedPositionIndices.length,
                        selectedPositions.length);
                this.selectedPositionIndices = indices;
            }
            this.selectedPositions = new ArrayList<>();
            haveToRecenterMap = selectedPositions.length > 0;
            haveToRepaintSelection = true;
            selectionUpdateReason = "selected " + selectedPositions.length + " positions; "
                    + "replacing selection: " + replaceSelection;
            notificationMutex.notifyAll();
        }
    }

    public void setSelectedPositions(List<NavigationPosition> selectedPositions) {
        synchronized (notificationMutex) {
            this.selectedPositions = selectedPositions;
            this.selectedPositionIndices = new int[0];
            haveToRecenterMap = selectedPositions.size() > 0;
            haveToRepaintSelection = true;
            selectionUpdateReason = "selected " + selectedPositions.size() + " positions without model";
            notificationMutex.notifyAll();
        }
    }

    protected void setShowCoordinates() {
        executeScript("setShowCoordinates(" + showCoordinates.getBoolean() + ");");
    }

    protected void setDegreeFormat() {
        executeScript("setDegreeFormat('" + unitSystemModel.getDegreeFormat() + "');");
    }

    @SuppressWarnings({ "unchecked", "Convert2Diamond" })
    public void showAllPositions() {
        setCenterOfMap(new ArrayList<NavigationPosition>(positionsModel.getRoute().getPositions()), true);
    }

    public void showMapBorder(BoundingBox mapBoundingBox) {
        throw new UnsupportedOperationException();
    }

    public NavigationPosition getCenter() {
        if (isInitialized())
            return getCurrentMapCenter();
        else
            return getLastMapCenter();
    }

    private int getZoom() {
        return preferences.getInt(CENTER_ZOOM_PREFERENCE, 2);
    }

    private void setZoom(int zoom) {
        preferences.putInt(CENTER_ZOOM_PREFERENCE, zoom);
    }

    // bounds and center

    protected abstract NavigationPosition getNorthEastBounds();

    protected abstract NavigationPosition getSouthWestBounds();

    protected abstract NavigationPosition getCurrentMapCenter();

    protected abstract Integer getCurrentZoom();

    protected abstract String getCallbacks();

    private NavigationPosition getLastMapCenter() {
        double latitude = preferences.getDouble(CENTER_LATITUDE_PREFERENCE, 35.0);
        double longitude = preferences.getDouble(CENTER_LONGITUDE_PREFERENCE, -25.0);
        return new SimpleNavigationPosition(longitude, latitude);
    }

    protected NavigationPosition parsePosition(String latLngString) {
        String result = executeScriptWithResult(latLngString);
        if (result == null)
            return null;

        StringTokenizer tokenizer = new StringTokenizer(result, ",");
        if (tokenizer.countTokens() != 2)
            return null;

        String latitude = tokenizer.nextToken();
        String longitude = tokenizer.nextToken();
        return parsePosition(latitude, longitude);
    }

    // WGS/GCJ conversion

    private boolean isFixMap(Double longitude, Double latitude) {
        FixMapMode fixMapMode = fixMapModeModel.getFixMapMode();
        return fixMapMode.equals(Yes)
                || fixMapMode.equals(Automatic) && isGoogleFixMap() && isPositionInChina(longitude, latitude);
    }

    private NavigationPosition parsePosition(String latitudeString, String longitudeString) {
        Double longitude = parseDouble(longitudeString);
        Double latitude = parseDouble(latitudeString);
        if (longitude != null && latitude != null && isFixMap(longitude, latitude)) {
            double[] delta = delta(latitude, longitude);
            longitude -= delta[1];
            latitude -= delta[0];
        }
        return new SimpleNavigationPosition(longitude, latitude);
    }

    private String asCoordinates(NavigationPosition position) {
        Double longitude = position.getLongitude();
        Double latitude = position.getLatitude();
        if (longitude != null && latitude != null && isFixMap(longitude, latitude)) {
            double[] delta = delta(latitude, longitude);
            longitude += delta[1];
            latitude += delta[0];
        }
        return latitude + "," + longitude;
    }

    // draw on map

    protected void update(boolean haveToReplaceRoute, boolean clearPositionReducer) {
        if (!isInitialized() || !getComponent().isShowing())
            return;

        synchronized (notificationMutex) {
            this.haveToUpdateRoute = true;
            routeUpdateReason = "update route";
            if (haveToReplaceRoute) {
                this.haveToReplaceRoute = true;
                routeUpdateReason = "replace route";
                this.haveToRepaintSelection = true;
                selectionUpdateReason = "replace route";
            }
            if (clearPositionReducer)
                positionReducer.clear();
            notificationMutex.notifyAll();
        }
    }

    private void updateRouteButDontRecenter() {
        // repaint route immediately, simulates update(true) without recentering
        synchronized (notificationMutex) {
            haveToRepaintRouteImmediately = true;
            routeUpdateReason = "update route but don't recenter";
            positionReducer.clear();
            notificationMutex.notifyAll();
        }
    }

    private void updateSelection() {
        synchronized (notificationMutex) {
            haveToRepaintSelection = true;
            selectionUpdateReason = "update selection";
            notificationMutex.notifyAll();
        }
    }

    private void removeDirections() {
        executeScript("removeOverlays();\nremoveDirections();");
    }

    String asColor(Color color) {
        return encodeByte((byte) color.getRed()) + encodeByte((byte) color.getGreen())
                + encodeByte((byte) color.getBlue());
    }

    private static final float MINIMUM_OPACITY = 0.3f;

    float asOpacity(Color color) {
        return MINIMUM_OPACITY + color.getAlpha() / 256f * (1 - MINIMUM_OPACITY);
    }

    private void addDirectionsToMap(List<NavigationPosition> positions) {
        resetDirections();

        // avoid throwing javascript exceptions if there is nothing to direct
        if (positions.size() < 2) {
            addMarkersToMap(positions);
            return;
        }

        generationId++;
        directionsPositions.addAll(positions);
        executeScript("removeOverlays();");

        String color = asColor(routeColorModel.getColor());
        float opacity = asOpacity(routeColorModel.getColor());
        int width = preferences.getInt(ROUTE_LINE_WIDTH_PREFERENCE, 5);
        int maximumRouteSegmentLength = positionReducer.getMaximumSegmentLength(Route);
        int directionsCount = ceiling(positions.size(), maximumRouteSegmentLength, false);
        for (int j = 0; j < directionsCount; j++) {
            StringBuilder waypoints = new StringBuilder();
            int start = max(0, j * maximumRouteSegmentLength - 1);
            int end = min(positions.size(), (j + 1) * maximumRouteSegmentLength) - 1;
            for (int i = start + 1; i < end; i++) {
                NavigationPosition position = positions.get(i);
                waypoints.append("{location: new google.maps.LatLng(").append(asCoordinates(position)).append(")}");
                if (i < end - 1)
                    waypoints.append(",");
            }
            NavigationPosition origin = positions.get(start);
            NavigationPosition destination = positions.get(end);
            StringBuilder buffer = new StringBuilder();
            buffer.append("renderDirections({origin: new google.maps.LatLng(").append(asCoordinates(origin))
                    .append("),");
            buffer.append("destination: new google.maps.LatLng(").append(asCoordinates(destination)).append("),");
            buffer.append("waypoints: [").append(waypoints).append("],")
                    .append("travelMode: google.maps.DirectionsTravelMode.")
                    .append(mapViewCallback.getTravelMode().getName().toUpperCase()).append(",");
            buffer.append("avoidFerries: ").append(mapViewCallback.isAvoidFerries()).append(",");
            buffer.append("avoidHighways: ").append(mapViewCallback.isAvoidHighways()).append(",");
            buffer.append("avoidTolls: ").append(mapViewCallback.isAvoidTolls()).append(",");
            buffer.append("region: \"").append(Locale.getDefault().getCountry().toLowerCase()).append("\"},");
            buffer.append(generationId).append(",");
            buffer.append(start).append(",");
            int startIndex = positionsModel.getIndex(origin);
            buffer.append(startIndex).append(",");
            boolean lastSegment = (j == directionsCount - 1);
            buffer.append(lastSegment).append(",\"#").append(color).append("\",").append(opacity).append(",")
                    .append(width).append(");\n");
            try {
                sleep(preferences.getInt("routeSegmentTimeout", 250));
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            executeScript(buffer.toString());
        }

        try {
            sleep(preferences.getInt("routeCompleteTimeout", 1000));
        } catch (InterruptedException e) {
            // intentionally left empty
        }
    }

    private void addPolylinesToMap(final List<NavigationPosition> reducedPositions,
            List<NavigationPosition> allPositions) {
        // display markers if there is no polyline to show
        if (reducedPositions.size() < 2) {
            addMarkersToMap(reducedPositions);
            return;
        }

        String color = asColor(trackColorModel.getColor());
        float opacity = asOpacity(trackColorModel.getColor());
        int width = preferences.getInt(TRACK_LINE_WIDTH_PREFERENCE, 2);
        int maximumPolylineSegmentLength = positionReducer.getMaximumSegmentLength(Track);
        int polylinesCount = ceiling(reducedPositions.size(), maximumPolylineSegmentLength, true);
        for (int j = 0; j < polylinesCount; j++) {
            StringBuilder latlngs = new StringBuilder();
            int minimum = max(0, j * maximumPolylineSegmentLength - 1);
            int maximum = min(reducedPositions.size(), (j + 1) * maximumPolylineSegmentLength);
            for (int i = minimum; i < maximum; i++) {
                NavigationPosition position = reducedPositions.get(i);
                latlngs.append("new google.maps.LatLng(").append(asCoordinates(position)).append(")");
                if (i < maximum - 1)
                    latlngs.append(",");
            }
            executeScript("addPolyline([" + latlngs + "],\"#" + color + "\"," + opacity + "," + width + ");");
        }

        addWaypointIconsToMap(allPositions);

        removeDirections();
    }

    private void addWaypointIconsToMap(List<NavigationPosition> positions) {
        if (!isColumbusTrack())
            return;

        List<NavigationPosition> reducedPositions = positionReducer.filterVisiblePositions(positions, getZoom());

        StringBuilder icons = new StringBuilder();
        for (int i = 0, c = reducedPositions.size(); i < c; i++) {
            NavigationPosition position = reducedPositions.get(i);
            Wgs84Position wgs84Position = Wgs84Position.class.cast(position);
            WaypointType waypointType = wgs84Position.getWaypointType();
            if (i == c - 1)
                waypointType = End;
            if (i == 0)
                waypointType = Start;

            if (waypointType != null && waypointType != Waypoint)
                icons.append("addWaypointIcon(new google.maps.LatLng(").append(asCoordinates(position))
                        .append("),\"").append(waypointType).append("\");\n");
        }
        executeScript(icons.toString());
    }

    private void addMarkersToMap(List<NavigationPosition> positions) {
        int maximumMarkerSegmentLength = positionReducer.getMaximumSegmentLength(Waypoints);
        int markersCount = ceiling(positions.size(), maximumMarkerSegmentLength, false);
        for (int j = 0; j < markersCount; j++) {
            StringBuilder buffer = new StringBuilder();
            int maximum = min(positions.size(), (j + 1) * maximumMarkerSegmentLength);
            for (int i = j * maximumMarkerSegmentLength; i < maximum; i++) {
                NavigationPosition position = positions.get(i);
                buffer.append("addMarker(new google.maps.LatLng(").append(asCoordinates(position)).append("),")
                        .append("\"").append(escape(position.getDescription())).append("\",")
                        .append(showWaypointDescription.getBoolean()).append(");\n");
            }
            executeScript(buffer.toString());
        }
        removeDirections();
    }

    private void setCenterOfMap(List<NavigationPosition> positions, boolean recenter) {
        StringBuilder buffer = new StringBuilder();

        boolean fitBoundsToPositions = positions.size() > 0 && recenter;
        if (fitBoundsToPositions) {
            BoundingBox boundingBox = new BoundingBox(positions);
            buffer.append("fitBounds(new google.maps.LatLng(").append(asCoordinates(boundingBox.getSouthWest()))
                    .append("),").append("new google.maps.LatLng(")
                    .append(asCoordinates(boundingBox.getNorthEast())).append("));\n");
            ignoreNextZoomCallback = true;
        }

        if (haveToInitializeMapOnFirstStart) {
            NavigationPosition center;
            // if there are positions right at the start center on them else take the last known center and zoom
            if (positions.size() > 0) {
                center = new BoundingBox(positions).getCenter();
            } else {
                int zoom = getZoom();
                buffer.append("setZoom(").append(zoom).append(");\n");
                center = getLastMapCenter();
            }
            buffer.append("setCenter(new google.maps.LatLng(").append(asCoordinates(center)).append("));\n");
        }
        executeScript(buffer.toString());
        haveToInitializeMapOnFirstStart = false;

        if (fitBoundsToPositions) {
            // need to update zoom since fitBounds() changes the zoom level without firing a notification
            Integer zoom = getCurrentZoom();
            if (zoom != null)
                setZoom(zoom);
        }
    }

    private void selectPositions(List<NavigationPosition> selectedPositions, NavigationPosition center) {
        lastSelectedPositions = new ArrayList<>(selectedPositions);

        StringBuilder buffer = new StringBuilder();
        for (int i = 0; i < selectedPositions.size(); i++) {
            NavigationPosition selectedPosition = selectedPositions.get(i);
            buffer.append("selectPosition(new google.maps.LatLng(").append(asCoordinates(selectedPosition))
                    .append("),").append("\"").append(escape(selectedPosition.getDescription())).append("\",")
                    .append(i).append(");\n");
        }

        if (center != null && center.hasCoordinates())
            buffer.append("panTo(new google.maps.LatLng(").append(asCoordinates(center)).append("));\n");
        buffer.append("removeSelectedPositions();");
        executeScript(buffer.toString());
    }

    private final Map<Integer, PositionPair> insertWaypointsQueue = new LinkedHashMap<>();
    private final ExecutorService insertWaypointsExecutor = newSingleThreadExecutor();

    private void insertWaypoints(final String mode, int[] startPositions) {
        final Map<Integer, PositionPair> addToQueue = new LinkedHashMap<>();
        Random random = new Random();
        synchronized (notificationMutex) {
            @SuppressWarnings("unchecked")
            List<NavigationPosition> positions = positionsModel.getRoute().getPositions();
            for (int i = 0; i < startPositions.length; i++) {
                // skip the very last position without successor
                if (i == positions.size() - 1 || i == startPositions.length - 1)
                    continue;
                addToQueue.put(random.nextInt(),
                        new PositionPair(positions.get(startPositions[i]), positions.get(startPositions[i] + 1)));
            }
        }

        synchronized (insertWaypointsQueue) {
            insertWaypointsQueue.putAll(addToQueue);
        }

        insertWaypointsExecutor.execute(new Runnable() {
            public void run() {
                for (Map.Entry<Integer, PositionPair> entry : addToQueue.entrySet()) {
                    NavigationPosition origin = entry.getValue().getFirst();
                    NavigationPosition destination = entry.getValue().getSecond();
                    executeScript(mode + "({" + "origin: new google.maps.LatLng(" + asCoordinates(origin) + "),"
                            + "destination: new google.maps.LatLng(" + asCoordinates(destination) + "),"
                            + "travelMode: google.maps.DirectionsTravelMode."
                            + mapViewCallback.getTravelMode().getName().toUpperCase() + "," + "avoidFerries: "
                            + mapViewCallback.isAvoidFerries() + "," + "avoidHighways: "
                            + mapViewCallback.isAvoidHighways() + "," + "avoidTolls: "
                            + mapViewCallback.isAvoidTolls() + "," + "region: \""
                            + Locale.getDefault().getCountry().toLowerCase() + "\"}," + entry.getKey() + ");\n");
                    try {
                        sleep(preferences.getInt("insertWaypointsSegmentTimeout", 1000));
                    } catch (InterruptedException e) {
                        // intentionally left empty
                    }
                }
            }
        });
    }

    private void insertWaypointsCallback(Integer key, List<String> parameters) {
        PositionPair pair;
        synchronized (insertWaypointsQueue) {
            pair = insertWaypointsQueue.remove(key);
        }

        if (parameters.size() < 5 || pair == null)
            return;

        final NavigationPosition before = pair.getFirst();
        NavigationPosition after = pair.getSecond();
        final BaseRoute route = parseRoute(parameters, before, after);
        @SuppressWarnings("unchecked")
        final List<NavigationPosition> positions = positionsModel.getRoute().getPositions();
        synchronized (notificationMutex) {
            int row = positions.indexOf(before) + 1;
            insertPositions(row, route);
        }
        invokeLater(new Runnable() {
            public void run() {
                int row;
                synchronized (notificationMutex) {
                    row = positions.indexOf(before) + 1;
                }
                complementPositions(row, route);
            }
        });
    }

    // call Google Maps API functions

    @SuppressWarnings("unused")
    public void insertAllWaypoints(int[] startPositions) {
        insertWaypoints("insertAllWaypoints", startPositions);
    }

    @SuppressWarnings("unused")
    public void insertOnlyTurnpoints(int[] startPositions) {
        insertWaypoints("insertOnlyTurnpoints", startPositions);
    }

    // script execution

    private String escape(String string) {
        if (string == null)
            return "";
        StringBuilder buffer = new StringBuilder(string);
        for (int i = 0; i < buffer.length(); i++) {
            char c = buffer.charAt(i);
            if (!(isLetterOrDigit(c) || isWhitespace(c) || c == '\'' || c == ',')) {
                buffer.deleteCharAt(i);
                i--;
            }
        }
        return buffer.toString();
    }

    protected void logJavaScript(String script, Object result) {
        log.info("Executed '" + script + (result != null ? "'\nwith result '" + result : "") + "'");
    }

    protected abstract void executeScript(String script);

    protected abstract String executeScriptWithResult(String script);

    // browser callbacks

    private void processStream(Socket socket) throws IOException {
        List<String> lines = new ArrayList<>();
        boolean processingPost = false, processingBody = false;
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()),
                64 * 1024)) {
            while (true) {
                try {
                    String line = trim(reader.readLine());
                    if (line == null) {
                        if (processingPost && !processingBody) {
                            processingBody = true;
                            continue;
                        } else
                            break;
                    }
                    if (line.startsWith("POST"))
                        processingPost = true;
                    lines.add(line);
                } catch (IOException e) {
                    log.severe("Cannot read line from callback listener port:" + e);
                    break;
                }
            }

            try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) {
                writer.write("HTTP/1.1 200 OK\n");
                writer.write("Content-Type: text/plain\n");
            }
        }

        StringBuilder buffer = new StringBuilder();
        for (String line : lines) {
            buffer.append("  ").append(line).append("\n");
        }
        log.fine("Processing callback @" + currentTimeMillis() + " from port " + socket.getPort() + ": \n"
                + buffer.toString());

        if (!isAuthenticated(lines))
            return;

        processLines(lines, socket.getPort());
    }

    private boolean isAuthenticated(List<String> lines) {
        Map<String, String> map = asMap(lines);
        String host = trim(map.get("Host"));
        return host != null && host.equals("127.0.0.1:" + getCallbackPort());
    }

    int getCallbackPort() {
        return callbackListenerServerSocket.getLocalPort();
    }

    private static final Pattern NAME_VALUE_PATTERN = Pattern.compile("^(.+?):(.+)$");

    private Map<String, String> asMap(List<String> lines) {
        Map<String, String> map = new HashMap<>();
        for (String line : lines) {
            Matcher matcher = NAME_VALUE_PATTERN.matcher(line);
            if (matcher.matches())
                map.put(matcher.group(1), matcher.group(2));
        }
        return map;
    }

    private static final Pattern CALLBACK_REQUEST_PATTERN = Pattern
            .compile("^(GET|OPTIONS|POST) /(\\d+)/(.*) HTTP.+$");
    private int lastCallbackNumber = -1;

    void processLines(List<String> lines, int port) {
        boolean hasValidCallbackNumber = false;
        for (String line : lines) {
            Matcher matcher = CALLBACK_REQUEST_PATTERN.matcher(line);
            if (matcher.matches()) {
                int callbackNumber = parseInt(matcher.group(2));
                if (lastCallbackNumber >= callbackNumber) {
                    log.info("Ignoring callback number: " + callbackNumber + " last callback number is: "
                            + lastCallbackNumber + " port is: " + port);
                    break;
                }
                lastCallbackNumber = callbackNumber;
                hasValidCallbackNumber = true;

                String callback = matcher.group(3);
                if (processCallback(callback)) {
                    log.info("Processed " + matcher.group(1) + " callback " + callback + " with number: "
                            + callbackNumber + " from port: " + port);
                    break;
                }
            }

            // process body of POST requests
            if (hasValidCallbackNumber && processCallback(line)) {
                log.info("Processed POST callback " + line + " with number: " + lastCallbackNumber + " from port: "
                        + port);
                break;
            }
        }
    }

    private static final Pattern ADD_POSITION_PATTERN = Pattern.compile("^add-position/(.*)/(.*)$");
    private static final Pattern ADD_POSITION_AT_PATTERN = Pattern.compile("^add-position-at/(.*)/(.*)/(.*)$");
    private static final Pattern MOVE_POSITION_PATTERN = Pattern.compile("^move-position/(.*)/(.*)/(.*)$");
    private static final Pattern DELETE_POSITION_PATTERN = Pattern.compile("^delete-position/(.*)/(.*)/(.*)$");
    private static final Pattern SELECT_POSITION_PATTERN = Pattern.compile("^select-position/(.*)/(.*)/(.*)/(.*)$");
    private static final Pattern SELECT_POSITIONS_PATTERN = Pattern
            .compile("^select-positions/(.*)/(.*)/(.*)/(.*)/(.*)");
    private static final Pattern MAP_TYPE_CHANGED_PATTERN = Pattern.compile("^map-type-changed/(.*)$");
    private static final Pattern ZOOM_CHANGED_PATTERN = Pattern.compile("^zoom-changed/(.*)$");
    private static final Pattern CENTER_CHANGED_PATTERN = Pattern
            .compile("^center-changed/(.*)/(.*)/(.*)/(.*)/(.*)/(.*)$");
    private static final Pattern CALLBACK_PORT_PATTERN = Pattern.compile("^callback-port/(\\d+)$");
    private static final Pattern OVER_QUERY_LIMIT_PATTERN = Pattern.compile("^over-query-limit$");
    private static final Pattern ZERO_RESULTS_PATTERN = Pattern.compile("^zero-results$");
    private static final Pattern INSERT_WAYPOINTS_PATTERN = Pattern
            .compile("^(Insert-All-Waypoints|Insert-Only-Turnpoints): (-?\\d+)/(.*)$");
    private static final Pattern DIRECTIONS_LOAD_PATTERN = Pattern.compile("^directions-load/(\\d+)/(\\d+)/(.*)$");

    boolean processCallback(String callback) {
        Matcher insertPositionAtMatcher = ADD_POSITION_AT_PATTERN.matcher(callback);
        if (insertPositionAtMatcher.matches()) {
            final int row = parseInt(insertPositionAtMatcher.group(1)) + 1;
            final NavigationPosition position = parsePosition(insertPositionAtMatcher.group(2),
                    insertPositionAtMatcher.group(3));
            invokeLater(new Runnable() {
                public void run() {
                    insertPosition(row, position.getLongitude(), position.getLatitude());
                }
            });
            return true;
        }

        Matcher insertPositionMatcher = ADD_POSITION_PATTERN.matcher(callback);
        if (insertPositionMatcher.matches()) {
            final int row = getAddRow();
            final NavigationPosition position = parsePosition(insertPositionMatcher.group(1),
                    insertPositionMatcher.group(2));
            invokeLater(new Runnable() {
                public void run() {
                    insertPosition(row, position.getLongitude(), position.getLatitude());
                }
            });
            return true;
        }

        Matcher movePositionMatcher = MOVE_POSITION_PATTERN.matcher(callback);
        if (movePositionMatcher.matches()) {
            final int row = getMoveRow(parseInt(movePositionMatcher.group(1)));
            final NavigationPosition position = parsePosition(movePositionMatcher.group(2),
                    movePositionMatcher.group(3));
            invokeLater(new Runnable() {
                public void run() {
                    movePosition(row, position.getLongitude(), position.getLatitude());
                }
            });
            return true;
        }

        Matcher deletePositionMatcher = DELETE_POSITION_PATTERN.matcher(callback);
        if (deletePositionMatcher.matches()) {
            final NavigationPosition position = parsePosition(deletePositionMatcher.group(1),
                    deletePositionMatcher.group(2));
            final Double threshold = parseDouble(deletePositionMatcher.group(3));
            invokeLater(new Runnable() {
                public void run() {
                    deletePosition(position.getLongitude(), position.getLatitude(), threshold);
                }
            });
            return true;
        }

        Matcher selectPositionMatcher = SELECT_POSITION_PATTERN.matcher(callback);
        if (selectPositionMatcher.matches()) {
            final NavigationPosition position = parsePosition(selectPositionMatcher.group(1),
                    selectPositionMatcher.group(2));
            final Double threshold = parseDouble(selectPositionMatcher.group(3));
            final Boolean replaceSelection = parseBoolean(selectPositionMatcher.group(4));
            invokeLater(new Runnable() {
                public void run() {
                    selectPosition(position.getLongitude(), position.getLatitude(), threshold, replaceSelection);
                }
            });
            return true;
        }

        Matcher selectPositionsMatcher = SELECT_POSITIONS_PATTERN.matcher(callback);
        if (selectPositionsMatcher.matches()) {
            NavigationPosition northEast = parsePosition(selectPositionsMatcher.group(1),
                    selectPositionsMatcher.group(2));
            NavigationPosition southWest = parsePosition(selectPositionsMatcher.group(3),
                    selectPositionsMatcher.group(4));
            final BoundingBox boundingBox = new BoundingBox(northEast, southWest);
            final Boolean replaceSelection = parseBoolean(selectPositionsMatcher.group(5));
            invokeLater(new Runnable() {
                public void run() {
                    selectPositions(boundingBox, replaceSelection);
                }
            });
            return true;
        }

        Matcher mapTypeChangedMatcher = MAP_TYPE_CHANGED_PATTERN.matcher(callback);
        if (mapTypeChangedMatcher.matches()) {
            String mapType = decodeUri(mapTypeChangedMatcher.group(1));
            mapTypeChanged(mapType);
            return true;
        }

        Matcher zoomChangedMatcher = ZOOM_CHANGED_PATTERN.matcher(callback);
        if (zoomChangedMatcher.matches()) {
            Integer zoom = parseInteger(zoomChangedMatcher.group(1));
            zoomChanged(zoom);
            return true;
        }

        Matcher centerChangedMatcher = CENTER_CHANGED_PATTERN.matcher(callback);
        if (centerChangedMatcher.matches()) {
            NavigationPosition center = parsePosition(centerChangedMatcher.group(1), centerChangedMatcher.group(2));
            NavigationPosition northEast = parsePosition(centerChangedMatcher.group(3),
                    centerChangedMatcher.group(4));
            NavigationPosition southWest = parsePosition(centerChangedMatcher.group(5),
                    centerChangedMatcher.group(6));
            BoundingBox boundingBox = new BoundingBox(northEast, southWest);
            centerChanged(center, boundingBox);
            return true;
        }

        Matcher callbackPortMatcher = CALLBACK_PORT_PATTERN.matcher(callback);
        if (callbackPortMatcher.matches()) {
            int port = parseInt(callbackPortMatcher.group(1));
            fireReceivedCallback(port);
            return true;
        }

        Matcher overQueryLimitMatcher = OVER_QUERY_LIMIT_PATTERN.matcher(callback);
        if (overQueryLimitMatcher.matches()) {
            overQueryLimitCount++;
            log.warning("Google Directions API is over query limit, count: " + overQueryLimitCount);
            return true;
        }

        Matcher zeroResultsMatcher = ZERO_RESULTS_PATTERN.matcher(callback);
        if (zeroResultsMatcher.matches()) {
            zeroResultsCount++;
            log.warning("Google Directions API returns zero results, count: " + zeroResultsCount);
            return true;
        }

        Matcher directionsLoadMatcher = DIRECTIONS_LOAD_PATTERN.matcher(callback);
        if (directionsLoadMatcher.matches()) {
            Integer generation = parseInt(directionsLoadMatcher.group(1));
            if (generation != generationId) {
                log.warning("Got directions load from generation id: " + generation + ", current: " + generationId);
            } else {
                Integer generationIndex = parseInt(directionsLoadMatcher.group(2));
                List<DistanceAndTime> distanceAndTimes = parseDistanceAndTimeParameters(
                        directionsLoadMatcher.group(3));
                directionsLoadCallback(generationIndex, distanceAndTimes);
            }
            return true;
        }

        Matcher insertWaypointsMatcher = INSERT_WAYPOINTS_PATTERN.matcher(callback);
        if (insertWaypointsMatcher.matches()) {
            Integer key = parseInteger(insertWaypointsMatcher.group(2));
            List<String> parameters = parsePositionParameters(insertWaypointsMatcher.group(3));
            insertWaypointsCallback(key, parameters);
            return true;
        }
        return false;
    }

    private void centerChanged(NavigationPosition center, BoundingBox boundingBox) {
        preferences.putDouble(CENTER_LATITUDE_PREFERENCE, center.getLatitude());
        preferences.putDouble(CENTER_LONGITUDE_PREFERENCE, center.getLongitude());

        if (positionReducer.hasFilteredVisibleArea()) {
            if (!positionReducer.isWithinVisibleArea(boundingBox)) {
                synchronized (notificationMutex) {
                    haveToRepaintRouteImmediately = true;
                    routeUpdateReason = "repaint not visible positions";
                    positionReducer.clear();
                    notificationMutex.notifyAll();
                }
            }
        }
    }

    private void zoomChanged(Integer zoom) {
        setZoom(zoom);
        synchronized (notificationMutex) {
            // since setCenter() leads to a callback and thus paints the track twice
            if (ignoreNextZoomCallback)
                ignoreNextZoomCallback = false;
            else if (// directions are automatically scaled by the Google Maps API when zooming
            !positionsModel.getRoute().getCharacteristics().equals(Route)
                    && (recenterAfterZooming.getBoolean() || positionReducer.hasFilteredVisibleArea())) {
                haveToRepaintRouteImmediately = true;
                // if enabled, recenter map to selected positions after zooming
                if (recenterAfterZooming.getBoolean())
                    haveToRecenterMap = true;
                haveToRepaintSelectionImmediately = true;
                selectionUpdateReason = "zoomed from " + lastZoom + " to " + zoom;
                lastZoom = zoom;
                notificationMutex.notifyAll();
            }
        }
    }

    private void mapTypeChanged(String mapType) {
        preferences.put(MAP_TYPE_PREFERENCE, mapType);
        if (fixMapModeModel.getFixMapMode().equals(Automatic)) {
            invokeLater(new Runnable() {
                public void run() {
                    update(false, false);
                }
            });
        }
    }

    private boolean isDuplicate(NavigationPosition position, NavigationPosition insert) {
        if (position == null)
            return false;
        Double distance = position.calculateDistance(insert);
        return toDouble(distance) < 10.0;
    }

    private String trimSpaces(String string) {
        if ("-".equals(string))
            return null;
        try {
            return trim(new String(string.getBytes(), UTF8_ENCODING));
        } catch (UnsupportedEncodingException e) {
            return null;
        }
    }

    private List<String> parsePositionParameters(String parameters) {
        List<String> result = new ArrayList<>();
        StringTokenizer tokenizer = new StringTokenizer(parameters, "/");
        while (tokenizer.hasMoreTokens()) {
            String latitude = trim(tokenizer.nextToken());
            if (tokenizer.hasMoreTokens()) {
                String longitude = trim(tokenizer.nextToken());
                if (tokenizer.hasMoreTokens()) {
                    String meters = trim(tokenizer.nextToken());
                    if (tokenizer.hasMoreTokens()) {
                        String seconds = trim(tokenizer.nextToken());
                        if (tokenizer.hasMoreTokens()) {
                            String instructions = trimSpaces(tokenizer.nextToken());
                            result.add(latitude);
                            result.add(longitude);
                            result.add(meters);
                            result.add(seconds);
                            result.add(instructions);
                        }
                    }
                }
            }
        }
        return result;
    }

    private List<DistanceAndTime> parseDistanceAndTimeParameters(String parameters) {
        List<DistanceAndTime> result = new ArrayList<>();
        StringTokenizer tokenizer = new StringTokenizer(parameters, "/");
        while (tokenizer.hasMoreTokens()) {
            String distance = trim(tokenizer.nextToken());
            if (tokenizer.hasMoreTokens()) {
                String time = trim(tokenizer.nextToken());
                result.add(new DistanceAndTime(parseDouble(distance), parseLong(time)));
            }
        }
        return result;
    }

    private Double parseSeconds(String string) {
        Double result = parseDouble(string);
        return !isEmpty(result) ? result : null;
    }

    @SuppressWarnings("unchecked")
    private BaseRoute parseRoute(List<String> parameters, NavigationPosition before, NavigationPosition after) {
        BaseRoute route = new NavigatingPoiWarnerFormat().createRoute(Waypoints, null,
                new ArrayList<NavigationPosition>());
        // count backwards as inserting at position 0
        CompactCalendar time = after.getTime();
        for (int i = parameters.size() - 1; i > 0; i -= 5) {
            String instructions = trim(parameters.get(i));
            Double seconds = parseSeconds(parameters.get(i - 1));
            // Double meters = parseDouble(parameters.get(i - 2));
            NavigationPosition coordinates = parsePosition(parameters.get(i - 4), parameters.get(i - 3));
            if (seconds != null && time != null) {
                Calendar calendar = time.getCalendar();
                calendar.add(SECOND, -seconds.intValue());
                time = fromCalendar(calendar);
            }
            BaseNavigationPosition position = route.createPosition(coordinates.getLongitude(),
                    coordinates.getLatitude(), null, null, seconds != null ? time : null, instructions);
            if (!isDuplicate(before, position) && !isDuplicate(after, position)) {
                route.add(0, position);
            }
        }
        return route;
    }

    @SuppressWarnings("unchecked")
    private void insertPositions(int row, BaseRoute route) {
        try {
            positionsModel.add(row, route);
        } catch (IOException e) {
            log.severe("Cannot insert route: " + e);
        }
    }

    private void complementPositions(int row, BaseRoute route) {
        int[] rows = asRange(row, row + route.getPositions().size() - 1);
        // do not complement description since this is limited to 2500 calls/day
        mapViewCallback.complementData(rows, false, true, true, false, false);
    }

    private void insertPosition(int row, Double longitude, Double latitude) {
        positionsModel.add(row, longitude, latitude, null, null, null,
                mapViewCallback.createDescription(positionsModel.getRowCount() + 1, null));
        int[] rows = new int[] { row };
        positionsSelectionModel.setSelectedPositions(rows, true);
        mapViewCallback.complementData(rows, true, true, true, true, false);
    }

    private int getAddRow() {
        NavigationPosition position = lastSelectedPositions.size() > 0
                ? lastSelectedPositions.get(lastSelectedPositions.size() - 1)
                : null;
        // quite crude logic to be as robust as possible on failures
        if (position == null && positionsModel.getRowCount() > 0)
            position = positionsModel.getPosition(positionsModel.getRowCount() - 1);
        return position != null ? positionsModel.getIndex(position) + 1 : 0;
    }

    private int getMoveRow(int index) {
        NavigationPosition position = lastSelectedPositions.get(index);
        final int row;
        synchronized (notificationMutex) {
            row = positionsModel.getRoute().getPositions().indexOf(position);
        }
        return row;
    }

    private void movePosition(int row, Double longitude, Double latitude) {
        NavigationPosition reference = positionsModel.getPosition(row);
        Double diffLongitude = reference != null ? longitude - reference.getLongitude() : 0.0;
        Double diffLatitude = reference != null ? latitude - reference.getLatitude() : 0.0;

        boolean moveCompleteSelection = preferences.getBoolean(MOVE_COMPLETE_SELECTION_PREFERENCE, true);
        boolean cleanElevation = preferences.getBoolean(CLEAN_ELEVATION_ON_MOVE_PREFERENCE, false);
        boolean complementElevation = preferences.getBoolean(COMPLEMENT_ELEVATION_ON_MOVE_PREFERENCE, true);
        boolean cleanTime = preferences.getBoolean(CLEAN_TIME_ON_MOVE_PREFERENCE, false);
        boolean complementTime = preferences.getBoolean(COMPLEMENT_TIME_ON_MOVE_PREFERENCE, true);

        int minimum = row;
        for (int index : selectedPositionIndices) {
            if (index < minimum)
                minimum = index;

            NavigationPosition position = positionsModel.getPosition(index);
            if (position == null)
                continue;

            if (index != row) {
                if (!moveCompleteSelection)
                    continue;

                positionsModel.edit(index,
                        new PositionColumnValues(asList(LONGITUDE_COLUMN_INDEX, LATITUDE_COLUMN_INDEX),
                                Arrays.<Object>asList(position.getLongitude() + diffLongitude,
                                        position.getLatitude() + diffLatitude)),
                        false, true);
            } else {
                positionsModel.edit(index,
                        new PositionColumnValues(asList(LONGITUDE_COLUMN_INDEX, LATITUDE_COLUMN_INDEX),
                                Arrays.<Object>asList(longitude, latitude)),
                        false, true);
            }

            if (cleanTime)
                positionsModel.edit(index, new PositionColumnValues(DATE_TIME_COLUMN_INDEX, null), false, false);
            if (cleanElevation)
                positionsModel.edit(index, new PositionColumnValues(ELEVATION_COLUMN_INDEX, null), false, false);

            if (complementTime || complementElevation)
                mapViewCallback.complementData(new int[] { index }, false, complementTime, complementElevation,
                        true, false);
        }

        // updating all rows behind the modified is quite expensive, but necessary due to the distance
        // calculation - if that didn't exist the single update of row would be sufficient
        int size;
        synchronized (notificationMutex) {
            size = positionsModel.getRoute().getPositions().size() - 1;
            haveToRepaintRouteImmediately = true;
            routeUpdateReason = "move position";
            positionReducer.clear();
            haveToRepaintSelectionImmediately = true;
            selectionUpdateReason = "move position";
        }
        positionsModel.fireTableRowsUpdated(minimum, size, ALL_COLUMNS);
    }

    private void selectPosition(Double longitude, Double latitude, Double threshold, boolean replaceSelection) {
        int row = positionsModel.getClosestPosition(longitude, latitude, threshold);
        if (row != -1)
            positionsSelectionModel.setSelectedPositions(new int[] { row }, replaceSelection);
    }

    private void selectPositions(BoundingBox boundingBox, boolean replaceSelection) {
        int[] rows = positionsModel.getContainedPositions(boundingBox);
        if (rows.length > 0) {
            positionsSelectionModel.setSelectedPositions(rows, replaceSelection);
        }
    }

    private void deletePosition(Double longitude, Double latitude, Double threshold) {
        int row = positionsModel.getClosestPosition(longitude, latitude, threshold);
        if (row != -1) {
            positionsModel.remove(new int[] { row });

            executor.execute(new Runnable() {
                public void run() {
                    synchronized (notificationMutex) {
                        haveToRepaintRouteImmediately = true;
                        routeUpdateReason = "delete position";
                        notificationMutex.notifyAll();
                    }
                }
            });
        }
    }

    private int generationId = 0;
    private List<NavigationPosition> directionsPositions = new ArrayList<>();
    private Map<Integer, DistanceAndTime> indexToDistanceAndTime = new HashMap<>();

    private void resetDirections() {
        directionsPositions.clear();
        indexToDistanceAndTime.clear();
    }

    private void directionsLoadCallback(final int generationIndex, final List<DistanceAndTime> distanceAndTimes) {
        executor.execute(new Runnable() {
            public void run() {
                for (int i = 0; i < distanceAndTimes.size(); i++) {
                    // find successor of start position from directions for first DistanceAndTime
                    NavigationPosition position = directionsPositions.get(generationIndex + i + 1);
                    int index = positionsModel.getIndex(position);
                    indexToDistanceAndTime.put(index, distanceAndTimes.get(i));
                }

                Map<Integer, DistanceAndTime> result = new HashMap<>(indexToDistanceAndTime.size());
                double aggregatedDistance = 0.0;
                long aggregatedTime = 0L;
                List<Integer> indices = new ArrayList<>(indexToDistanceAndTime.keySet());
                sort(indices);
                for (Integer index : indices) {
                    DistanceAndTime distanceAndTime = indexToDistanceAndTime.get(index);
                    if (distanceAndTime != null) {
                        Double distance = distanceAndTime.getDistance();
                        if (!isEmpty(distance))
                            aggregatedDistance += distance;
                        Long time = distanceAndTime.getTime();
                        if (!isEmpty(time))
                            aggregatedTime += time;
                    }
                    result.put(index, new DistanceAndTime(aggregatedDistance, aggregatedTime));
                }
                fireCalculatedDistances(result);
            }
        });
    }

    // listeners

    private final List<MapViewListener> mapViewListeners = new CopyOnWriteArrayList<>();

    public void addMapViewListener(MapViewListener listener) {
        mapViewListeners.add(listener);
    }

    public void removeMapViewListener(MapViewListener listener) {
        mapViewListeners.remove(listener);
    }

    private void fireCalculatedDistances(Map<Integer, DistanceAndTime> indexToDistanceAndTime) {
        for (MapViewListener listener : mapViewListeners) {
            listener.calculatedDistances(indexToDistanceAndTime);
        }
    }

    private void fireReceivedCallback(int port) {
        for (MapViewListener listener : mapViewListeners) {
            listener.receivedCallback(port);
        }
    }

    private class PositionsModelListener implements TableModelListener {
        public void tableChanged(TableModelEvent e) {
            boolean insertOrDelete = e.getType() == INSERT || e.getType() == DELETE;
            boolean allRowsChanged = isFirstToLastRow(e);
            // used to be limited to single rows which did work reliably but with usability problems
            // if (e.getFirstRow() == e.getLastRow() && insertOrDelete)
            if (!allRowsChanged && insertOrDelete)
                updateRouteButDontRecenter();
            else {
                // ignored updates on columns not displayed
                if (e.getType() == UPDATE
                        && !(e.getColumn() == DESCRIPTION_COLUMN_INDEX || e.getColumn() == LONGITUDE_COLUMN_INDEX
                                || e.getColumn() == LATITUDE_COLUMN_INDEX || e.getColumn() == ALL_COLUMNS))
                    return;

                if (showAllPositionsAfterLoading.getBoolean())
                    update(allRowsChanged, true);
                else
                    updateRouteButDontRecenter();
            }

            // update position marker on updates of longitude and latitude
            if (e.getType() == UPDATE
                    && (e.getColumn() == LONGITUDE_COLUMN_INDEX || e.getColumn() == LATITUDE_COLUMN_INDEX
                            || e.getColumn() == DESCRIPTION_COLUMN_INDEX || e.getColumn() == ALL_COLUMNS)) {
                for (int selectedPositionIndex : selectedPositionIndices) {
                    if (selectedPositionIndex >= e.getFirstRow() && selectedPositionIndex <= e.getLastRow()) {
                        updateSelection();
                        break;
                    }
                }
            }
        }
    }

    private class CharacteristicsModelListener implements ListDataListener {
        public void intervalAdded(ListDataEvent e) {
        }

        public void intervalRemoved(ListDataEvent e) {
        }

        public void contentsChanged(ListDataEvent e) {
            // ignore events following setRoute()
            if (e.getType() == CONTENTS_CHANGED && e.getIndex0() == IGNORE && e.getIndex1() == IGNORE)
                return;
            updateRouteButDontRecenter();
        }
    }

    private class MapViewCallbackListener implements ChangeListener {
        public void stateChanged(ChangeEvent e) {
            if (positionsModel.getRoute().getCharacteristics().equals(Route))
                update(false, false);
        }
    }

    private class ShowCoordinatesListener implements ChangeListener {
        public void stateChanged(ChangeEvent e) {
            setShowCoordinates();
        }
    }

    private class ShowWaypointDescriptionListener implements ChangeListener {
        public void stateChanged(ChangeEvent e) {
            if (positionsModel.getRoute().getCharacteristics().equals(Waypoints))
                update(false, false);
        }
    }

    private class RepaintPositionListListener implements ChangeListener {
        public void stateChanged(ChangeEvent e) {
            update(true, false);
        }
    }

    private class GoogleMapsServerListener implements ChangeListener {
        public void stateChanged(ChangeEvent e) {
            initializeWebPage();
        }
    }

    private class UnitSystemListener implements ChangeListener {
        public void stateChanged(ChangeEvent e) {
            setDegreeFormat();
        }
    }
}