001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trc;
006
007import java.awt.Component;
008import java.awt.GraphicsEnvironment;
009import java.awt.MenuComponent;
010import java.awt.event.ActionEvent;
011import java.util.ArrayList;
012import java.util.Collection;
013import java.util.Comparator;
014import java.util.EnumMap;
015import java.util.List;
016import java.util.Locale;
017import java.util.Map;
018import java.util.Map.Entry;
019import java.util.stream.Collectors;
020
021import javax.swing.Action;
022import javax.swing.JComponent;
023import javax.swing.JMenu;
024import javax.swing.JMenuItem;
025import javax.swing.JPopupMenu;
026import javax.swing.event.MenuEvent;
027import javax.swing.event.MenuListener;
028
029import org.openstreetmap.josm.actions.AddImageryLayerAction;
030import org.openstreetmap.josm.actions.JosmAction;
031import org.openstreetmap.josm.actions.MapRectifierWMSmenuAction;
032import org.openstreetmap.josm.data.coor.LatLon;
033import org.openstreetmap.josm.data.imagery.ImageryInfo;
034import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryCategory;
035import org.openstreetmap.josm.data.imagery.ImageryLayerInfo;
036import org.openstreetmap.josm.data.imagery.Shape;
037import org.openstreetmap.josm.gui.layer.ImageryLayer;
038import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
039import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
040import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
041import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
042import org.openstreetmap.josm.gui.preferences.imagery.ImageryPreference;
043import org.openstreetmap.josm.tools.ImageProvider;
044import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
045import org.openstreetmap.josm.tools.Logging;
046import org.openstreetmap.josm.tools.Utils;
047
048/**
049 * Imagery menu, holding entries for imagery preferences, offset actions and dynamic imagery entries
050 * depending on current mapview coordinates.
051 * @since 3737
052 */
053public class ImageryMenu extends JMenu implements LayerChangeListener {
054
055    static final class AdjustImageryOffsetAction extends JosmAction {
056
057        AdjustImageryOffsetAction() {
058            super(tr("Imagery offset"), "mapmode/adjustimg", tr("Adjust imagery offset"), null, false, false);
059            setToolbarId("imagery-offset");
060            MainApplication.getToolbar().register(this);
061        }
062
063        @Override
064        public void actionPerformed(ActionEvent e) {
065            Collection<ImageryLayer> layers = MainApplication.getLayerManager().getLayersOfType(ImageryLayer.class);
066            if (layers.isEmpty()) {
067                setEnabled(false);
068                return;
069            }
070            Component source = null;
071            if (e.getSource() instanceof Component) {
072                source = (Component) e.getSource();
073            }
074            JPopupMenu popup = new JPopupMenu();
075            if (layers.size() == 1) {
076                JComponent c = layers.iterator().next().getOffsetMenuItem(popup);
077                if (c instanceof JMenuItem) {
078                    ((JMenuItem) c).getAction().actionPerformed(e);
079                } else {
080                    if (source == null || !source.isShowing()) return;
081                    popup.show(source, source.getWidth()/2, source.getHeight()/2);
082                }
083                return;
084            }
085            if (source == null || !source.isShowing()) return;
086            for (ImageryLayer layer : layers) {
087                JMenuItem layerMenu = layer.getOffsetMenuItem();
088                layerMenu.setText(layer.getName());
089                layerMenu.setIcon(layer.getIcon());
090                popup.add(layerMenu);
091            }
092            popup.show(source, source.getWidth()/2, source.getHeight()/2);
093        }
094    }
095
096    /**
097     * Compare ImageryInfo objects alphabetically by name.
098     *
099     * ImageryInfo objects are normally sorted by country code first
100     * (for the preferences). We don't want this in the imagery menu.
101     */
102    public static final Comparator<ImageryInfo> alphabeticImageryComparator =
103            Comparator.comparing(ii -> ii.getName().toLowerCase(Locale.ENGLISH));
104
105    private final transient Action offsetAction = new AdjustImageryOffsetAction();
106
107    private final JMenuItem singleOffset = new JMenuItem(offsetAction);
108    private JMenuItem offsetMenuItem = singleOffset;
109    private final MapRectifierWMSmenuAction rectaction = new MapRectifierWMSmenuAction();
110
111    /**
112     * Constructs a new {@code ImageryMenu}.
113     * @param subMenu submenu in that contains plugin-managed additional imagery layers
114     */
115    public ImageryMenu(JMenu subMenu) {
116        /* I18N: mnemonic: I */
117        super(trc("menu", "Imagery"));
118        setupMenuScroller();
119        MainApplication.getLayerManager().addLayerChangeListener(this);
120        // build dynamically
121        addMenuListener(new MenuListener() {
122            @Override
123            public void menuSelected(MenuEvent e) {
124                refreshImageryMenu();
125            }
126
127            @Override
128            public void menuDeselected(MenuEvent e) {
129                // Do nothing
130            }
131
132            @Override
133            public void menuCanceled(MenuEvent e) {
134                // Do nothing
135            }
136        });
137        MainMenu.add(subMenu, rectaction);
138    }
139
140    private void setupMenuScroller() {
141        if (!GraphicsEnvironment.isHeadless()) {
142            MenuScroller.setScrollerFor(this, 150, 2);
143        }
144    }
145
146    /**
147     * For layers containing complex shapes, check that center is in one of its shapes (fix #7910)
148     * @param info layer info
149     * @param pos center
150     * @return {@code true} if center is in one of info shapes
151     */
152    private static boolean isPosInOneShapeIfAny(ImageryInfo info, LatLon pos) {
153        List<Shape> shapes = info.getBounds().getShapes();
154        return Utils.isEmpty(shapes) || shapes.stream().anyMatch(s -> s.contains(pos));
155    }
156
157    /**
158     * Refresh imagery menu.
159     *
160     * Outside this class only called in {@link ImageryPreference#initialize()}.
161     * (In order to have actions ready for the toolbar, see #8446.)
162     */
163    public void refreshImageryMenu() {
164        removeDynamicItems();
165
166        addDynamic(offsetMenuItem);
167        addDynamicSeparator();
168
169        // for each configured ImageryInfo, add a menu entry.
170        final List<ImageryInfo> savedLayers = new ArrayList<>(ImageryLayerInfo.instance.getLayers());
171        savedLayers.sort(alphabeticImageryComparator);
172        for (final ImageryInfo u : savedLayers) {
173            addDynamic(trackJosmAction(new AddImageryLayerAction(u)), null);
174        }
175
176        // list all imagery entries where the current map location is within the imagery bounds
177        if (MainApplication.isDisplayingMapView()) {
178            MapView mv = MainApplication.getMap().mapView;
179            LatLon pos = mv.getProjection().eastNorth2latlon(mv.getCenter());
180            final List<ImageryInfo> alreadyInUse = ImageryLayerInfo.instance.getLayers();
181            final List<ImageryInfo> inViewLayers = ImageryLayerInfo.instance.getDefaultLayers()
182                    .stream().filter(i -> i.getBounds() != null && i.getBounds().contains(pos)
183                        && !alreadyInUse.contains(i) && isPosInOneShapeIfAny(i, pos))
184                    .sorted(alphabeticImageryComparator)
185                    .collect(Collectors.toList());
186            if (!inViewLayers.isEmpty()) {
187                if (inViewLayers.stream().anyMatch(i -> i.getImageryCategory() == ImageryCategory.PHOTO)) {
188                    addDynamicSeparator();
189                }
190                for (ImageryInfo i : inViewLayers) {
191                    addDynamic(trackJosmAction(new AddImageryLayerAction(i)), i.getImageryCategory());
192                }
193            }
194            if (!dynamicNonPhotoItems.isEmpty()) {
195                addDynamicSeparator();
196                for (Entry<ImageryCategory, List<JMenuItem>> e : dynamicNonPhotoItems.entrySet()) {
197                    ImageryCategory cat = e.getKey();
198                    List<JMenuItem> list = e.getValue();
199                    if (list.size() > 1) {
200                        JMenuItem categoryMenu = new JMenu(cat.getDescription());
201                        categoryMenu.setIcon(cat.getIcon(ImageSizes.MENU));
202                        for (JMenuItem it : list) {
203                            categoryMenu.add(it);
204                        }
205                        dynamicNonPhotoMenus.add(add(categoryMenu));
206                    } else if (!list.isEmpty()) {
207                        dynamicNonPhotoMenus.add(add(list.get(0)));
208                    }
209                }
210            }
211        }
212
213        addDynamicSeparator();
214        JMenu subMenu = MainApplication.getMenu().imagerySubMenu;
215        int heightUnrolled = 30*(getItemCount()+subMenu.getItemCount());
216        if (heightUnrolled < MainApplication.getMainPanel().getHeight()) {
217            // add all items of submenu if they will fit on screen
218            int n = subMenu.getItemCount();
219            for (int i = 0; i < n; i++) {
220                addDynamic(subMenu.getItem(i).getAction(), null);
221            }
222        } else {
223            // or add the submenu itself
224            addDynamic(subMenu);
225        }
226    }
227
228    private JMenuItem getNewOffsetMenu() {
229        Collection<ImageryLayer> layers = MainApplication.getLayerManager().getLayersOfType(ImageryLayer.class);
230        if (layers.isEmpty()) {
231            offsetAction.setEnabled(false);
232            return singleOffset;
233        }
234        offsetAction.setEnabled(true);
235        JMenu newMenu = new JMenu(trc("layer", "Offset"));
236        newMenu.setIcon(ImageProvider.get("mapmode", "adjustimg"));
237        newMenu.setAction(offsetAction);
238        if (layers.size() == 1)
239            return (JMenuItem) layers.iterator().next().getOffsetMenuItem(newMenu);
240        for (ImageryLayer layer : layers) {
241            JMenuItem layerMenu = layer.getOffsetMenuItem();
242            layerMenu.setText(layer.getName());
243            layerMenu.setIcon(layer.getIcon());
244            newMenu.add(layerMenu);
245        }
246        return newMenu;
247    }
248
249    /**
250     * Refresh offset menu item.
251     */
252    public void refreshOffsetMenu() {
253        offsetMenuItem = getNewOffsetMenu();
254    }
255
256    @Override
257    public void layerAdded(LayerAddEvent e) {
258        if (e.getAddedLayer() instanceof ImageryLayer) {
259            refreshOffsetMenu();
260        }
261    }
262
263    @Override
264    public void layerRemoving(LayerRemoveEvent e) {
265        if (e.getRemovedLayer() instanceof ImageryLayer) {
266            refreshOffsetMenu();
267        }
268    }
269
270    @Override
271    public void layerOrderChanged(LayerOrderChangeEvent e) {
272        refreshOffsetMenu();
273    }
274
275    /**
276     * List to store temporary "photo" menu items. They will be deleted
277     * (and possibly recreated) when refreshImageryMenu() is called.
278     */
279    private final List<Object> dynamicItems = new ArrayList<>(20);
280    /**
281     * Map to store temporary "not photo" menu items. They will be deleted
282     * (and possibly recreated) when refreshImageryMenu() is called.
283     */
284    private final Map<ImageryCategory, List<JMenuItem>> dynamicNonPhotoItems = new EnumMap<>(ImageryCategory.class);
285    /**
286     * List to store temporary "not photo" submenus. They will be deleted
287     * (and possibly recreated) when refreshImageryMenu() is called.
288     */
289    private final List<JMenuItem> dynamicNonPhotoMenus = new ArrayList<>(20);
290    private final List<JosmAction> dynJosmActions = new ArrayList<>(20);
291
292    /**
293     * Remove all the items in dynamic items collection
294     * @since 5803
295     */
296    private void removeDynamicItems() {
297        dynJosmActions.forEach(JosmAction::destroy);
298        dynJosmActions.clear();
299        dynamicItems.forEach(this::removeDynamicItem);
300        dynamicItems.clear();
301        dynamicNonPhotoMenus.forEach(this::removeDynamicItem);
302        dynamicItems.clear();
303        dynamicNonPhotoItems.clear();
304    }
305
306    private void removeDynamicItem(Object item) {
307        if (item instanceof JMenuItem) {
308            remove((JMenuItem) item);
309        } else if (item instanceof MenuComponent) {
310            remove((MenuComponent) item);
311        } else if (item instanceof Component) {
312            remove((Component) item);
313        } else {
314            Logging.error("Unknown imagery menu item type: {0}", item);
315        }
316    }
317
318    private void addDynamicSeparator() {
319        JPopupMenu.Separator s = new JPopupMenu.Separator();
320        dynamicItems.add(s);
321        add(s);
322    }
323
324    private void addDynamic(Action a, ImageryCategory category) {
325        JMenuItem item = createActionComponent(a);
326        item.setAction(a);
327        doAddDynamic(item, category);
328    }
329
330    private void addDynamic(JMenuItem it) {
331        doAddDynamic(it, null);
332    }
333
334    private void doAddDynamic(JMenuItem item, ImageryCategory category) {
335        if (category == null || category == ImageryCategory.PHOTO) {
336            dynamicItems.add(this.add(item));
337        } else {
338            dynamicNonPhotoItems.computeIfAbsent(category, x -> new ArrayList<>()).add(item);
339        }
340    }
341
342    private Action trackJosmAction(Action action) {
343        if (action instanceof JosmAction) {
344            dynJosmActions.add((JosmAction) action);
345        }
346        return action;
347    }
348
349}