// License: WTFPL. For details, see LICENSE file. package iodb; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Timer; import java.util.TimerTask; import java.util.TreeMap; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.data.coor.LatLon; import org.openstreetmap.josm.data.imagery.OffsetBookmark; import org.openstreetmap.josm.gui.MapView; import org.openstreetmap.josm.gui.NavigatableComponent.ZoomChangeListener; import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer; import org.openstreetmap.josm.gui.layer.Layer; import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent; import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings; import org.openstreetmap.josm.tools.Destroyable; /** * This class watches imagery layer offsets and notifies listeners when there's a need to update offset * for the current layer. * * @author Zverik * @license WTFPL */ public final class ImageryOffsetWatcher implements ZoomChangeListener, LayerChangeListener, ActiveLayerChangeListener, Destroyable { private static final double THRESHOLD = 1e-8; private static ImageryOffsetWatcher instance; private Map<Integer, ImageryLayerData> layers = new TreeMap<>(); private List<OffsetStateListener> listeners = new ArrayList<>(); private Timer time; private double maxDistance; private boolean offsetGood = true; /** * Create an instance and register it as a listener to MapView. * Also starts a timer task. */ private ImageryOffsetWatcher() { maxDistance = Main.pref.getDouble("iodb.offset.radius", 15); MapView.addZoomChangeListener(this); Main.getLayerManager().addLayerChangeListener(this); Main.getLayerManager().addActiveLayerChangeListener(this); checkOffset(); // we assume there's at the most one imagery layer at this moment time = new Timer(); time.schedule(new IntervalOffsetChecker(), 0, 2000); } /** * Unregister all events. This actually gets never called, but it's not a problem. */ @Override public void destroy() { MapView.removeZoomChangeListener(this); Main.getLayerManager().removeLayerChangeListener(this); Main.getLayerManager().removeActiveLayerChangeListener(this); time.cancel(); } /** * This class is a singleton, this method returns the instance, * creating it if neccessary. */ public static ImageryOffsetWatcher getInstance() { if (instance == null) { instance = new ImageryOffsetWatcher(); } return instance; } /** * Register an offset state listener. */ public void register(OffsetStateListener listener) { listeners.add(listener); listener.offsetStateChanged(offsetGood); } /** * Unregister an offset state listener. */ public void unregister(OffsetStateListener listener) { listeners.remove(listener); } /** * Change stored offset state, notify listeners if needed. */ private void setOffsetGood(boolean good) { if (good != offsetGood) { for (OffsetStateListener listener : listeners) { listener.offsetStateChanged(good); } } offsetGood = good; } /** * Check if the offset state has been changed. */ private synchronized void checkOffset() { if (maxDistance <= 0) { setOffsetGood(true); return; } AbstractTileSourceLayer layer = ImageryOffsetTools.getTopImageryLayer(); if (layer == null) { setOffsetGood(true); return; } TileSourceDisplaySettings displaySettings = layer.getDisplaySettings(); LatLon center = ImageryOffsetTools.getMapCenter(); Integer hash = layer.hashCode(); ImageryLayerData data = layers.get(hash); if (data == null) { // create entry for this layer and mark as needing alignment data = new ImageryLayerData(); data.lastDx = displaySettings.getDx(); data.lastDy = displaySettings.getDy(); boolean r = false; if (Math.abs(data.lastDx) + Math.abs(data.lastDy) > THRESHOLD) { data.lastChecked = center; r = true; } layers.put(hash, data); setOffsetGood(r); } else { // now, we have a returning layer. if (Math.abs(data.lastDx - displaySettings.getDx()) + Math.abs(data.lastDy - displaySettings.getDy()) > THRESHOLD) { // offset has changed, record the current position data.lastDx = displaySettings.getDx(); data.lastDy = displaySettings.getDy(); data.lastChecked = center; storeLayerOffset(layer); setOffsetGood(true); } else { setOffsetGood(data.lastChecked != null && center.greatCircleDistance(data.lastChecked) <= maxDistance * 1000); } } } /** * Mark the current offset as good. This method is called by {@link OffsetDialog} * to notify the watcher that an offset button has been clicked, and regardless of * whether it has changed an offset, the currect imagery alignment is ok. */ public void markGood() { AbstractTileSourceLayer layer = ImageryOffsetTools.getTopImageryLayer(); if (layer != null) { TileSourceDisplaySettings displaySettings = layer.getDisplaySettings(); LatLon center = ImageryOffsetTools.getMapCenter(); Integer hash = layer.hashCode(); ImageryLayerData data = layers.get(hash); if (data == null) { // create entry for this layer and mark as good data = new ImageryLayerData(); data.lastDx = displaySettings.getDx(); data.lastDy = displaySettings.getDy(); data.lastChecked = center; layers.put(hash, data); } else { data.lastDx = displaySettings.getDx(); data.lastDy = displaySettings.getDy(); data.lastChecked = center; } storeLayerOffset(layer); } setOffsetGood(true); } /** * This class stores an offset and last location for a single imagery layer. * All fields are public, because this is not enterprise. */ private static class ImageryLayerData { public double lastDx = 0.0; public double lastDy = 0.0; public LatLon lastChecked; } @Override public void zoomChanged() { checkOffset(); } @Override public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) { checkOffset(); } @Override public void layerAdded(LayerAddEvent e) { Layer newLayer = e.getAddedLayer(); if (newLayer instanceof AbstractTileSourceLayer) loadLayerOffset((AbstractTileSourceLayer) newLayer); checkOffset(); } @Override public void layerRemoving(LayerRemoveEvent e) { checkOffset(); } @Override public void layerOrderChanged(LayerOrderChangeEvent e) { } /** * Saves the current imagery layer offset to preferences. It is stored as a * collection of ':'-separated strings: imagery_id:lat:lon:dx:dy. No need for * projections: nobody uses them anyway. */ private void storeLayerOffset(AbstractTileSourceLayer layer) { String id = ImageryOffsetTools.getImageryID(layer); if (!Main.pref.getBoolean("iodb.remember.offsets", true) || id == null) return; Collection<String> offsets = new LinkedList<>(Main.pref.getCollection("iodb.stored.offsets")); for (Iterator<String> iter = offsets.iterator(); iter.hasNext();) { String[] offset = iter.next().split(":"); if (offset.length == 5 && offset[0].equals(id)) iter.remove(); } LatLon center = ImageryOffsetTools.getMapCenter(); offsets.add(id + ":" + center.lat() + ":" + center.lon() + ":" + layer.getDisplaySettings().getDx() + ":" + layer.getDisplaySettings().getDy()); Main.pref.putCollection("iodb.stored.offsets", offsets); } /** * Loads the current imagery layer offset from preferences. */ private void loadLayerOffset(AbstractTileSourceLayer layer) { String id = ImageryOffsetTools.getImageryID(layer); if (!Main.pref.getBoolean("iodb.remember.offsets", true) || id == null) return; Collection<String> offsets = Main.pref.getCollection("iodb.stored.offsets"); for (String offset : offsets) { String[] parts = offset.split(":"); if (parts.length == 5 && parts[0].equals(id)) { double[] dparts = new double[4]; try { for (int i = 0; i < 4; i++) { dparts[i] = Double.parseDouble(parts[i+1]); } } catch (NumberFormatException e) { continue; } LatLon lastPos = new LatLon(dparts[0], dparts[1]); if (lastPos.greatCircleDistance(ImageryOffsetTools.getMapCenter()) < Math.max(maxDistance, 3.0) * 1000) { // apply offset OffsetBookmark bookmark = new OffsetBookmark(Main.getProjection().toCode(), layer.getName(), "Restored", dparts[2], dparts[3]); layer.getDisplaySettings().setOffsetBookmark(bookmark); return; } } } } /** * This task is run every 1-2 seconds. */ private class IntervalOffsetChecker extends TimerTask { /** * Reread max radius setting and update offset state. */ @Override public void run() { maxDistance = Main.pref.getDouble("iodb.offset.radius", 15); checkOffset(); } } /** * The interface for offset listeners. */ public interface OffsetStateListener { void offsetStateChanged(boolean isOffsetGood); } }