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