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