001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.marktr;
006import static org.openstreetmap.josm.tools.I18n.tr;
007
008import java.awt.event.ActionEvent;
009import java.awt.event.KeyEvent;
010import java.awt.geom.Area;
011import java.util.ArrayList;
012import java.util.Arrays;
013import java.util.Collection;
014import java.util.Collections;
015import java.util.HashSet;
016import java.util.List;
017import java.util.concurrent.TimeUnit;
018
019import javax.swing.JOptionPane;
020import javax.swing.event.ListSelectionListener;
021import javax.swing.event.TreeSelectionListener;
022
023import org.openstreetmap.josm.Main;
024import org.openstreetmap.josm.data.Bounds;
025import org.openstreetmap.josm.data.DataSource;
026import org.openstreetmap.josm.data.conflict.Conflict;
027import org.openstreetmap.josm.data.osm.DataSet;
028import org.openstreetmap.josm.data.osm.IPrimitive;
029import org.openstreetmap.josm.data.osm.OsmData;
030import org.openstreetmap.josm.data.osm.OsmPrimitive;
031import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
032import org.openstreetmap.josm.data.validation.TestError;
033import org.openstreetmap.josm.gui.MainApplication;
034import org.openstreetmap.josm.gui.MapFrame;
035import org.openstreetmap.josm.gui.MapFrameListener;
036import org.openstreetmap.josm.gui.MapView;
037import org.openstreetmap.josm.gui.dialogs.ConflictDialog;
038import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
039import org.openstreetmap.josm.gui.dialogs.ValidatorDialog.ValidatorBoundingXYVisitor;
040import org.openstreetmap.josm.gui.layer.Layer;
041import org.openstreetmap.josm.spi.preferences.Config;
042import org.openstreetmap.josm.tools.Logging;
043import org.openstreetmap.josm.tools.Shortcut;
044
045/**
046 * Toggles the autoScale feature of the mapView
047 * @author imi
048 */
049public class AutoScaleAction extends JosmAction {
050
051    /**
052     * A list of things we can zoom to. The zoom target is given depending on the mode.
053     */
054    public static final Collection<String> MODES = Collections.unmodifiableList(Arrays.asList(
055        marktr(/* ICON(dialogs/autoscale/) */ "data"),
056        marktr(/* ICON(dialogs/autoscale/) */ "layer"),
057        marktr(/* ICON(dialogs/autoscale/) */ "selection"),
058        marktr(/* ICON(dialogs/autoscale/) */ "conflict"),
059        marktr(/* ICON(dialogs/autoscale/) */ "download"),
060        marktr(/* ICON(dialogs/autoscale/) */ "problem"),
061        marktr(/* ICON(dialogs/autoscale/) */ "previous"),
062        marktr(/* ICON(dialogs/autoscale/) */ "next")));
063
064    /**
065     * One of {@link #MODES}. Defines what we are zooming to.
066     */
067    private final String mode;
068
069    /** Time of last zoom to bounds action */
070    protected long lastZoomTime = -1;
071    /** Last zommed bounds */
072    protected int lastZoomArea = -1;
073
074    /**
075     * Zooms the current map view to the currently selected primitives.
076     * Does nothing if there either isn't a current map view or if there isn't a current data layer.
077     *
078     */
079    public static void zoomToSelection() {
080        OsmData<?, ?, ?, ?> dataSet = MainApplication.getLayerManager().getActiveData();
081        if (dataSet == null) {
082            return;
083        }
084        Collection<? extends IPrimitive> sel = dataSet.getSelected();
085        if (sel.isEmpty()) {
086            JOptionPane.showMessageDialog(
087                    Main.parent,
088                    tr("Nothing selected to zoom to."),
089                    tr("Information"),
090                    JOptionPane.INFORMATION_MESSAGE);
091            return;
092        }
093        zoomTo(sel);
094    }
095
096    /**
097     * Zooms the view to display the given set of primitives.
098     * @param sel The primitives to zoom to, e.g. the current selection.
099     */
100    public static void zoomTo(Collection<? extends IPrimitive> sel) {
101        BoundingXYVisitor bboxCalculator = new BoundingXYVisitor();
102        bboxCalculator.computeBoundingBox(sel);
103        // increase bbox. This is required
104        // especially if the bbox contains one single node, but helpful
105        // in most other cases as well.
106        bboxCalculator.enlargeBoundingBox();
107        if (bboxCalculator.getBounds() != null) {
108            MainApplication.getMap().mapView.zoomTo(bboxCalculator);
109        }
110    }
111
112    /**
113     * Performs the auto scale operation of the given mode without the need to create a new action.
114     * @param mode One of {@link #MODES}.
115     */
116    public static void autoScale(String mode) {
117        new AutoScaleAction(mode, false).autoScale();
118    }
119
120    private static int getModeShortcut(String mode) {
121        int shortcut = -1;
122
123        // TODO: convert this to switch/case and make sure the parsing still works
124        // CHECKSTYLE.OFF: LeftCurly
125        // CHECKSTYLE.OFF: RightCurly
126        /* leave as single line for shortcut overview parsing! */
127        if (mode.equals("data")) { shortcut = KeyEvent.VK_1; }
128        else if (mode.equals("layer")) { shortcut = KeyEvent.VK_2; }
129        else if (mode.equals("selection")) { shortcut = KeyEvent.VK_3; }
130        else if (mode.equals("conflict")) { shortcut = KeyEvent.VK_4; }
131        else if (mode.equals("download")) { shortcut = KeyEvent.VK_5; }
132        else if (mode.equals("problem")) { shortcut = KeyEvent.VK_6; }
133        else if (mode.equals("previous")) { shortcut = KeyEvent.VK_8; }
134        else if (mode.equals("next")) { shortcut = KeyEvent.VK_9; }
135        // CHECKSTYLE.ON: LeftCurly
136        // CHECKSTYLE.ON: RightCurly
137
138        return shortcut;
139    }
140
141    /**
142     * Constructs a new {@code AutoScaleAction}.
143     * @param mode The autoscale mode (one of {@link AutoScaleAction#MODES})
144     * @param marker Must be set to false. Used only to differentiate from default constructor
145     */
146    private AutoScaleAction(String mode, boolean marker) {
147        super(marker);
148        this.mode = mode;
149    }
150
151    /**
152     * Constructs a new {@code AutoScaleAction}.
153     * @param mode The autoscale mode (one of {@link AutoScaleAction#MODES})
154     */
155    public AutoScaleAction(final String mode) {
156        super(tr("Zoom to {0}", tr(mode)), "dialogs/autoscale/" + mode, tr("Zoom the view to {0}.", tr(mode)),
157                Shortcut.registerShortcut("view:zoom" + mode, tr("View: {0}", tr("Zoom to {0}", tr(mode))),
158                        getModeShortcut(mode), Shortcut.DIRECT), true, null, false);
159        String modeHelp = Character.toUpperCase(mode.charAt(0)) + mode.substring(1);
160        putValue("help", "Action/AutoScale/" + modeHelp);
161        this.mode = mode;
162        switch (mode) {
163        case "data":
164            putValue("help", ht("/Action/ZoomToData"));
165            break;
166        case "layer":
167            putValue("help", ht("/Action/ZoomToLayer"));
168            break;
169        case "selection":
170            putValue("help", ht("/Action/ZoomToSelection"));
171            break;
172        case "conflict":
173            putValue("help", ht("/Action/ZoomToConflict"));
174            break;
175        case "problem":
176            putValue("help", ht("/Action/ZoomToProblem"));
177            break;
178        case "download":
179            putValue("help", ht("/Action/ZoomToDownload"));
180            break;
181        case "previous":
182            putValue("help", ht("/Action/ZoomToPrevious"));
183            break;
184        case "next":
185            putValue("help", ht("/Action/ZoomToNext"));
186            break;
187        default:
188            throw new IllegalArgumentException("Unknown mode: " + mode);
189        }
190        installAdapters();
191    }
192
193    /**
194     * Performs this auto scale operation for the mode this action is in.
195     */
196    public void autoScale() {
197        if (MainApplication.isDisplayingMapView()) {
198            MapView mapView = MainApplication.getMap().mapView;
199            switch (mode) {
200            case "previous":
201                mapView.zoomPrevious();
202                break;
203            case "next":
204                mapView.zoomNext();
205                break;
206            default:
207                BoundingXYVisitor bbox = getBoundingBox();
208                if (bbox != null && bbox.getBounds() != null) {
209                    mapView.zoomTo(bbox);
210                }
211            }
212        }
213        putValue("active", Boolean.TRUE);
214    }
215
216    @Override
217    public void actionPerformed(ActionEvent e) {
218        autoScale();
219    }
220
221    /**
222     * Replies the first selected layer in the layer list dialog. null, if no
223     * such layer exists, either because the layer list dialog is not yet created
224     * or because no layer is selected.
225     *
226     * @return the first selected layer in the layer list dialog
227     */
228    protected Layer getFirstSelectedLayer() {
229        if (getLayerManager().getActiveLayer() == null) {
230            return null;
231        }
232        try {
233            List<Layer> layers = LayerListDialog.getInstance().getModel().getSelectedLayers();
234            if (!layers.isEmpty())
235                return layers.get(0);
236        } catch (IllegalStateException e) {
237            Logging.error(e);
238        }
239        return null;
240    }
241
242    private BoundingXYVisitor getBoundingBox() {
243        switch (mode) {
244        case "problem":
245            return modeProblem(new ValidatorBoundingXYVisitor());
246        case "data":
247            return modeData(new BoundingXYVisitor());
248        case "layer":
249            return modeLayer(new BoundingXYVisitor());
250        case "selection":
251        case "conflict":
252            return modeSelectionOrConflict(new BoundingXYVisitor());
253        case "download":
254            return modeDownload(new BoundingXYVisitor());
255        default:
256            return new BoundingXYVisitor();
257        }
258    }
259
260    private static BoundingXYVisitor modeProblem(ValidatorBoundingXYVisitor v) {
261        TestError error = MainApplication.getMap().validatorDialog.getSelectedError();
262        if (error == null)
263            return null;
264        v.visit(error);
265        if (v.getBounds() == null)
266            return null;
267        v.enlargeBoundingBox(Config.getPref().getDouble("validator.zoom-enlarge-bbox", 0.0002));
268        return v;
269    }
270
271    private static BoundingXYVisitor modeData(BoundingXYVisitor v) {
272        for (Layer l : MainApplication.getLayerManager().getLayers()) {
273            l.visitBoundingBox(v);
274        }
275        return v;
276    }
277
278    private BoundingXYVisitor modeLayer(BoundingXYVisitor v) {
279        // try to zoom to the first selected layer
280        Layer l = getFirstSelectedLayer();
281        if (l == null)
282            return null;
283        l.visitBoundingBox(v);
284        return v;
285    }
286
287    private BoundingXYVisitor modeSelectionOrConflict(BoundingXYVisitor v) {
288        Collection<IPrimitive> sel = new HashSet<>();
289        if ("selection".equals(mode)) {
290            OsmData<?, ?, ?, ?> dataSet = getLayerManager().getActiveData();
291            if (dataSet != null) {
292                sel.addAll(dataSet.getSelected());
293            }
294        } else {
295            ConflictDialog conflictDialog = MainApplication.getMap().conflictDialog;
296            Conflict<? extends IPrimitive> c = conflictDialog.getSelectedConflict();
297            if (c != null) {
298                sel.add(c.getMy());
299            } else if (conflictDialog.getConflicts() != null) {
300                sel.addAll(conflictDialog.getConflicts().getMyConflictParties());
301            }
302        }
303        if (sel.isEmpty()) {
304            JOptionPane.showMessageDialog(
305                    Main.parent,
306                    "selection".equals(mode) ? tr("Nothing selected to zoom to.") : tr("No conflicts to zoom to"),
307                    tr("Information"),
308                    JOptionPane.INFORMATION_MESSAGE);
309            return null;
310        }
311        for (IPrimitive osm : sel) {
312            osm.accept(v);
313        }
314
315        // Increase the bounding box by up to 100% to give more context.
316        v.enlargeBoundingBoxLogarithmically(100);
317        // Make the bounding box at least 100 meter wide to
318        // ensure reasonable zoom level when zooming onto single nodes.
319        v.enlargeToMinSize(Config.getPref().getDouble("zoom_to_selection_min_size_in_meter", 100));
320        return v;
321    }
322
323    private BoundingXYVisitor modeDownload(BoundingXYVisitor v) {
324        if (lastZoomTime > 0 &&
325                System.currentTimeMillis() - lastZoomTime > Config.getPref().getLong("zoom.bounds.reset.time", TimeUnit.SECONDS.toMillis(10))) {
326            lastZoomTime = -1;
327        }
328        final DataSet dataset = getLayerManager().getActiveDataSet();
329        if (dataset != null) {
330            List<DataSource> dataSources = new ArrayList<>(dataset.getDataSources());
331            int s = dataSources.size();
332            if (s > 0) {
333                if (lastZoomTime == -1 || lastZoomArea == -1 || lastZoomArea > s) {
334                    lastZoomArea = s-1;
335                    v.visit(dataSources.get(lastZoomArea).bounds);
336                } else if (lastZoomArea > 0) {
337                    lastZoomArea -= 1;
338                    v.visit(dataSources.get(lastZoomArea).bounds);
339                } else {
340                    lastZoomArea = -1;
341                    Area sourceArea = getLayerManager().getActiveDataSet().getDataSourceArea();
342                    if (sourceArea != null) {
343                        v.visit(new Bounds(sourceArea.getBounds2D()));
344                    }
345                }
346                lastZoomTime = System.currentTimeMillis();
347            } else {
348                lastZoomTime = -1;
349                lastZoomArea = -1;
350            }
351        }
352        return v;
353    }
354
355    @Override
356    protected void updateEnabledState() {
357        OsmData<?, ?, ?, ?> ds = getLayerManager().getActiveData();
358        MapFrame map = MainApplication.getMap();
359        switch (mode) {
360        case "selection":
361            setEnabled(ds != null && !ds.selectionEmpty());
362            break;
363        case "layer":
364            setEnabled(getFirstSelectedLayer() != null);
365            break;
366        case "conflict":
367            setEnabled(map != null && map.conflictDialog.getSelectedConflict() != null);
368            break;
369        case "download":
370            setEnabled(ds != null && !ds.getDataSources().isEmpty());
371            break;
372        case "problem":
373            setEnabled(map != null && map.validatorDialog.getSelectedError() != null);
374            break;
375        case "previous":
376            setEnabled(MainApplication.isDisplayingMapView() && map.mapView.hasZoomUndoEntries());
377            break;
378        case "next":
379            setEnabled(MainApplication.isDisplayingMapView() && map.mapView.hasZoomRedoEntries());
380            break;
381        default:
382            setEnabled(!getLayerManager().getLayers().isEmpty());
383        }
384    }
385
386    @Override
387    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
388        if ("selection".equals(mode)) {
389            setEnabled(selection != null && !selection.isEmpty());
390        }
391    }
392
393    @Override
394    protected final void installAdapters() {
395        super.installAdapters();
396        // make this action listen to zoom and mapframe change events
397        //
398        MapView.addZoomChangeListener(new ZoomChangeAdapter());
399        MainApplication.addMapFrameListener(new MapFrameAdapter());
400        initEnabledState();
401    }
402
403    /**
404     * Adapter for zoom change events
405     */
406    private class ZoomChangeAdapter implements MapView.ZoomChangeListener {
407        @Override
408        public void zoomChanged() {
409            updateEnabledState();
410        }
411    }
412
413    /**
414     * Adapter for MapFrame change events
415     */
416    private class MapFrameAdapter implements MapFrameListener {
417        private ListSelectionListener conflictSelectionListener;
418        private TreeSelectionListener validatorSelectionListener;
419
420        MapFrameAdapter() {
421            if ("conflict".equals(mode)) {
422                conflictSelectionListener = e -> updateEnabledState();
423            } else if ("problem".equals(mode)) {
424                validatorSelectionListener = e -> updateEnabledState();
425            }
426        }
427
428        @Override
429        public void mapFrameInitialized(MapFrame oldFrame, MapFrame newFrame) {
430            if (conflictSelectionListener != null) {
431                if (newFrame != null) {
432                    newFrame.conflictDialog.addListSelectionListener(conflictSelectionListener);
433                } else if (oldFrame != null) {
434                    oldFrame.conflictDialog.removeListSelectionListener(conflictSelectionListener);
435                }
436            } else if (validatorSelectionListener != null) {
437                if (newFrame != null) {
438                    newFrame.validatorDialog.addTreeSelectionListener(validatorSelectionListener);
439                } else if (oldFrame != null) {
440                    oldFrame.validatorDialog.removeTreeSelectionListener(validatorSelectionListener);
441                }
442            }
443            updateEnabledState();
444        }
445    }
446}