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