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