001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.gpx;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.GridBagLayout;
010import java.awt.event.ActionEvent;
011import java.awt.event.MouseAdapter;
012import java.awt.event.MouseEvent;
013import java.awt.event.MouseListener;
014import java.io.Serializable;
015import java.util.Arrays;
016import java.util.Comparator;
017import java.util.Map;
018import java.util.Optional;
019
020import javax.swing.AbstractAction;
021import javax.swing.JComponent;
022import javax.swing.JLabel;
023import javax.swing.JPanel;
024import javax.swing.JScrollPane;
025import javax.swing.JTable;
026import javax.swing.JToggleButton;
027import javax.swing.ListSelectionModel;
028import javax.swing.table.DefaultTableModel;
029import javax.swing.table.TableCellRenderer;
030import javax.swing.table.TableRowSorter;
031
032import org.openstreetmap.josm.data.SystemOfMeasurement;
033import org.openstreetmap.josm.data.gpx.GpxConstants;
034import org.openstreetmap.josm.data.gpx.GpxTrack;
035import org.openstreetmap.josm.gui.ExtendedDialog;
036import org.openstreetmap.josm.gui.MainApplication;
037import org.openstreetmap.josm.gui.layer.GpxLayer;
038import org.openstreetmap.josm.gui.util.WindowGeometry;
039import org.openstreetmap.josm.tools.GBC;
040import org.openstreetmap.josm.tools.ImageProvider;
041import org.openstreetmap.josm.tools.OpenBrowser;
042
043/**
044 * allows the user to choose which of the downloaded tracks should be displayed.
045 * they can be chosen from the gpx layer context menu.
046 */
047public class ChooseTrackVisibilityAction extends AbstractAction {
048    private final transient GpxLayer layer;
049
050    private DateFilterPanel dateFilter;
051    private JTable table;
052
053    /**
054     * Constructs a new {@code ChooseTrackVisibilityAction}.
055     * @param layer The associated GPX layer
056     */
057    public ChooseTrackVisibilityAction(final GpxLayer layer) {
058        super(tr("Choose visible tracks"));
059        new ImageProvider("dialogs/filter").getResource().attachImageIcon(this, true);
060        this.layer = layer;
061        putValue("help", ht("/Action/ChooseTrackVisibility"));
062    }
063
064    /**
065     * Class to format a length according to SystemOfMesurement.
066     */
067    private static final class TrackLength {
068        private final double value;
069
070        /**
071         * Constructs a new {@code TrackLength} object with a given length.
072         * @param value length of the track
073         */
074        TrackLength(double value) {
075            this.value = value;
076        }
077
078        /**
079         * Provides string representation.
080         * @return String representation depending of SystemOfMeasurement
081         */
082        @Override
083        public String toString() {
084            return SystemOfMeasurement.getSystemOfMeasurement().getDistText(value);
085        }
086    }
087
088    /**
089     * Comparator for TrackLength objects
090     */
091    private static final class LengthContentComparator implements Comparator<TrackLength>, Serializable {
092
093        private static final long serialVersionUID = 1L;
094
095        /**
096         * Compare 2 TrackLength objects relative to the real length
097         */
098        @Override
099        public int compare(TrackLength l0, TrackLength l1) {
100            return Double.compare(l0.value, l1.value);
101        }
102    }
103
104    /**
105     * Gathers all available data for the tracks and returns them as array of arrays
106     * in the expected column order.
107     * @return table data
108     */
109    private Object[][] buildTableContents() {
110        Object[][] tracks = new Object[layer.data.tracks.size()][5];
111        int i = 0;
112        for (GpxTrack trk : layer.data.tracks) {
113            Map<String, Object> attr = trk.getAttributes();
114            String name = (String) Optional.ofNullable(attr.get(GpxConstants.GPX_NAME)).orElse("");
115            String desc = (String) Optional.ofNullable(attr.get(GpxConstants.GPX_DESC)).orElse("");
116            String time = GpxLayer.getTimespanForTrack(trk);
117            TrackLength length = new TrackLength(trk.length());
118            String url = (String) Optional.ofNullable(attr.get("url")).orElse("");
119            tracks[i] = new Object[]{name, desc, time, length, url};
120            i++;
121        }
122        return tracks;
123    }
124
125    /**
126     * Builds an non-editable table whose 5th column will open a browser when double clicked.
127     * The table will fill its parent.
128     * @param content table data
129     * @return non-editable table
130     */
131    private static JTable buildTable(Object[]... content) {
132        final String[] headers = {tr("Name"), tr("Description"), tr("Timespan"), tr("Length"), tr("URL")};
133        DefaultTableModel model = new DefaultTableModel(content, headers);
134        final JTable t = new JTable(model) {
135            @Override
136            public Component prepareRenderer(TableCellRenderer renderer, int row, int col) {
137                Component c = super.prepareRenderer(renderer, row, col);
138                if (c instanceof JComponent) {
139                    JComponent jc = (JComponent) c;
140                    jc.setToolTipText(getValueAt(row, col).toString());
141                }
142                return c;
143            }
144
145            @Override
146            public boolean isCellEditable(int rowIndex, int colIndex) {
147                return false;
148            }
149        };
150        // define how to sort row
151        TableRowSorter<DefaultTableModel> rowSorter = new TableRowSorter<>();
152        t.setRowSorter(rowSorter);
153        rowSorter.setModel(model);
154        rowSorter.setComparator(3, new LengthContentComparator());
155        // default column widths
156        t.getColumnModel().getColumn(0).setPreferredWidth(220);
157        t.getColumnModel().getColumn(1).setPreferredWidth(300);
158        t.getColumnModel().getColumn(2).setPreferredWidth(200);
159        t.getColumnModel().getColumn(3).setPreferredWidth(50);
160        t.getColumnModel().getColumn(4).setPreferredWidth(100);
161        // make the link clickable
162        final MouseListener urlOpener = new MouseAdapter() {
163            @Override
164            public void mouseClicked(MouseEvent e) {
165                if (e.getClickCount() != 2) {
166                    return;
167                }
168                JTable t = (JTable) e.getSource();
169                int col = t.convertColumnIndexToModel(t.columnAtPoint(e.getPoint()));
170                if (col != 4) {
171                    return;
172                }
173                int row = t.rowAtPoint(e.getPoint());
174                String url = (String) t.getValueAt(row, col);
175                if (url == null || url.isEmpty()) {
176                    return;
177                }
178                OpenBrowser.displayUrl(url);
179            }
180        };
181        t.addMouseListener(urlOpener);
182        t.setFillsViewportHeight(true);
183        return t;
184    }
185
186    private boolean noUpdates;
187
188    /** selects all rows (=tracks) in the table that are currently visible on the layer*/
189    private void selectVisibleTracksInTable() {
190        // don't select any tracks if the layer is not visible
191        if (!layer.isVisible()) {
192            return;
193        }
194        ListSelectionModel s = table.getSelectionModel();
195        s.setValueIsAdjusting(true);
196        s.clearSelection();
197        for (int i = 0; i < layer.trackVisibility.length; i++) {
198            if (layer.trackVisibility[i]) {
199                s.addSelectionInterval(i, i);
200            }
201        }
202        s.setValueIsAdjusting(false);
203    }
204
205    /** listens to selection changes in the table and redraws the map */
206    private void listenToSelectionChanges() {
207        table.getSelectionModel().addListSelectionListener(e -> {
208            if (noUpdates || !(e.getSource() instanceof ListSelectionModel)) {
209                return;
210            }
211            updateVisibilityFromTable();
212        });
213    }
214
215    private void updateVisibilityFromTable() {
216        ListSelectionModel s = table.getSelectionModel();
217        for (int i = 0; i < layer.trackVisibility.length; i++) {
218            layer.trackVisibility[table.convertRowIndexToModel(i)] = s.isSelectedIndex(i);
219        }
220        layer.invalidate();
221    }
222
223    @Override
224    public void actionPerformed(ActionEvent ae) {
225        final JPanel msg = new JPanel(new GridBagLayout());
226
227        dateFilter = new DateFilterPanel(layer, "gpx.traces", false);
228        dateFilter.setFilterAppliedListener(e -> {
229            noUpdates = true;
230            selectVisibleTracksInTable();
231            noUpdates = false;
232            layer.invalidate();
233        });
234        dateFilter.loadFromPrefs();
235
236        final JToggleButton b = new JToggleButton(new AbstractAction(tr("Select by date")) {
237            @Override public void actionPerformed(ActionEvent e) {
238                if (((JToggleButton) e.getSource()).isSelected()) {
239                    dateFilter.setEnabled(true);
240                    dateFilter.applyFilter();
241                } else {
242                    dateFilter.setEnabled(false);
243                }
244            }
245        });
246        dateFilter.setEnabled(false);
247        msg.add(b, GBC.std().insets(0, 0, 5, 0));
248        msg.add(dateFilter, GBC.eol().insets(0, 0, 10, 0).fill(GBC.HORIZONTAL));
249
250        msg.add(new JLabel(tr("<html>Select all tracks that you want to be displayed. " +
251                "You can drag select a range of tracks or use CTRL+Click to select specific ones. " +
252                "The map is updated live in the background. Open the URLs by double clicking them.</html>")),
253                GBC.eop().fill(GBC.HORIZONTAL));
254        // build table
255        final boolean[] trackVisibilityBackup = layer.trackVisibility.clone();
256        table = buildTable(buildTableContents());
257        selectVisibleTracksInTable();
258        listenToSelectionChanges();
259        // make the table scrollable
260        JScrollPane scrollPane = new JScrollPane(table);
261        msg.add(scrollPane, GBC.eol().fill(GBC.BOTH));
262
263        int v = 1;
264        // build dialog
265        ExtendedDialog ed = new ExtendedDialog(MainApplication.getMainFrame(), tr("Set track visibility for {0}", layer.getName()),
266                tr("Show all"), tr("Show selected only"), tr("Cancel"));
267        ed.setButtonIcons("eye", "dialogs/filter", "cancel");
268        ed.setContent(msg, false);
269        ed.setDefaultButton(2);
270        ed.setCancelButton(3);
271        ed.configureContextsensitiveHelp("/Action/ChooseTrackVisibility", true);
272        ed.setRememberWindowGeometry(getClass().getName() + ".geometry",
273                WindowGeometry.centerInWindow(MainApplication.getMainFrame(), new Dimension(1000, 500)));
274        ed.showDialog();
275        dateFilter.saveInPrefs();
276        v = ed.getValue();
277        // cancel for unknown buttons and copy back original settings
278        if (v != 1 && v != 2) {
279            layer.trackVisibility = Arrays.copyOf(trackVisibilityBackup, layer.trackVisibility.length);
280            MainApplication.getMap().repaint();
281            return;
282        }
283        // set visibility (1 = show all, 2 = filter). If no tracks are selected
284        // set all of them visible and...
285        ListSelectionModel s = table.getSelectionModel();
286        final boolean all = v == 1 || s.isSelectionEmpty();
287        for (int i = 0; i < layer.trackVisibility.length; i++) {
288            layer.trackVisibility[table.convertRowIndexToModel(i)] = all || s.isSelectedIndex(i);
289        }
290        // layer has been changed
291        layer.invalidate();
292        // ...sync with layer visibility instead to avoid having two ways to hide everything
293        layer.setVisible(v == 1 || !s.isSelectionEmpty());
294    }
295}