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.Color;
008import java.awt.Component;
009import java.awt.Dimension;
010import java.awt.GridBagLayout;
011import java.awt.event.ActionEvent;
012import java.awt.event.MouseAdapter;
013import java.awt.event.MouseEvent;
014import java.awt.event.MouseListener;
015import java.io.Serializable;
016import java.util.ArrayList;
017import java.util.Arrays;
018import java.util.Comparator;
019import java.util.List;
020import java.util.Map;
021import java.util.Objects;
022import java.util.Optional;
023
024import javax.swing.AbstractAction;
025import javax.swing.JColorChooser;
026import javax.swing.JComponent;
027import javax.swing.JLabel;
028import javax.swing.JOptionPane;
029import javax.swing.JPanel;
030import javax.swing.JScrollPane;
031import javax.swing.JTable;
032import javax.swing.JToggleButton;
033import javax.swing.ListSelectionModel;
034import javax.swing.event.TableModelEvent;
035import javax.swing.table.DefaultTableModel;
036import javax.swing.table.TableCellRenderer;
037import javax.swing.table.TableModel;
038import javax.swing.table.TableRowSorter;
039
040import org.apache.commons.jcs.access.exception.InvalidArgumentException;
041import org.openstreetmap.josm.data.SystemOfMeasurement;
042import org.openstreetmap.josm.data.gpx.GpxConstants;
043import org.openstreetmap.josm.data.gpx.IGpxTrack;
044import org.openstreetmap.josm.gui.ExtendedDialog;
045import org.openstreetmap.josm.gui.MainApplication;
046import org.openstreetmap.josm.gui.layer.GpxLayer;
047import org.openstreetmap.josm.gui.preferences.display.GPXSettingsPanel;
048import org.openstreetmap.josm.gui.util.WindowGeometry;
049import org.openstreetmap.josm.tools.GBC;
050import org.openstreetmap.josm.tools.ImageProvider;
051import org.openstreetmap.josm.tools.OpenBrowser;
052
053/**
054 * allows the user to choose which of the downloaded tracks should be displayed.
055 * they can be chosen from the gpx layer context menu.
056 */
057public class ChooseTrackVisibilityAction extends AbstractAction {
058    private final transient GpxLayer layer;
059
060    private DateFilterPanel dateFilter;
061    private JTable table;
062
063    /**
064     * Constructs a new {@code ChooseTrackVisibilityAction}.
065     * @param layer The associated GPX layer
066     */
067    public ChooseTrackVisibilityAction(final GpxLayer layer) {
068        super(tr("Choose track visibility and colors"));
069        new ImageProvider("dialogs/filter").getResource().attachImageIcon(this, true);
070        this.layer = layer;
071        putValue("help", ht("/Action/ChooseTrackVisibility"));
072    }
073
074    /**
075     * Class to format a length according to SystemOfMesurement.
076     */
077    private static final class TrackLength {
078        private final double value;
079
080        /**
081         * Constructs a new {@code TrackLength} object with a given length.
082         * @param value length of the track
083         */
084        TrackLength(double value) {
085            this.value = value;
086        }
087
088        /**
089         * Provides string representation.
090         * @return String representation depending of SystemOfMeasurement
091         */
092        @Override
093        public String toString() {
094            return SystemOfMeasurement.getSystemOfMeasurement().getDistText(value);
095        }
096    }
097
098    /**
099     * Comparator for TrackLength objects
100     */
101    private static final class LengthContentComparator implements Comparator<TrackLength>, Serializable {
102
103        private static final long serialVersionUID = 1L;
104
105        /**
106         * Compare 2 TrackLength objects relative to the real length
107         */
108        @Override
109        public int compare(TrackLength l0, TrackLength l1) {
110            return Double.compare(l0.value, l1.value);
111        }
112    }
113
114    /**
115     * Gathers all available data for the tracks and returns them as array of arrays
116     * in the expected column order.
117     * @return table data
118     */
119    private Object[][] buildTableContents() {
120        Object[][] tracks = new Object[layer.data.tracks.size()][5];
121        int i = 0;
122        for (IGpxTrack trk : layer.data.tracks) {
123            Map<String, Object> attr = trk.getAttributes();
124            String name = (String) Optional.ofNullable(attr.get(GpxConstants.GPX_NAME)).orElse("");
125            String desc = (String) Optional.ofNullable(attr.get(GpxConstants.GPX_DESC)).orElse("");
126            String time = GpxLayer.getTimespanForTrack(trk);
127            TrackLength length = new TrackLength(trk.length());
128            String url = (String) Optional.ofNullable(attr.get("url")).orElse("");
129            tracks[i] = new Object[]{name, desc, time, length, url, trk};
130            i++;
131        }
132        return tracks;
133    }
134
135    private void showColorDialog(List<IGpxTrack> tracks) {
136        Color cl = tracks.stream().filter(Objects::nonNull)
137                .map(IGpxTrack::getColor).filter(Objects::nonNull)
138                .findAny().orElse(GpxDrawHelper.DEFAULT_COLOR_PROPERTY.get());
139        JColorChooser c = new JColorChooser(cl);
140        Object[] options = new Object[]{tr("OK"), tr("Cancel"), tr("Default")};
141        int answer = JOptionPane.showOptionDialog(
142                MainApplication.getMainFrame(),
143                c,
144                tr("Choose a color"),
145                JOptionPane.OK_CANCEL_OPTION,
146                JOptionPane.PLAIN_MESSAGE,
147                null,
148                options,
149                options[0]
150        );
151        switch (answer) {
152        case 0:
153            tracks.stream().forEach(t -> t.setColor(c.getColor()));
154            GPXSettingsPanel.putLayerPrefLocal(layer, "colormode", "0"); //set Colormode to none
155            break;
156        case 1:
157            return;
158        case 2:
159            tracks.stream().forEach(t -> t.setColor(null));
160            break;
161        }
162        table.repaint();
163    }
164
165    /**
166     * Builds an editable table whose 5th column will open a browser when double clicked.
167     * The table will fill its parent.
168     * @param content table data
169     * @return non-editable table
170     */
171    private static JTable buildTable(Object[]... content) {
172        final String[] headers = {tr("Name"), tr("Description"), tr("Timespan"), tr("Length"), tr("URL")};
173        DefaultTableModel model = new DefaultTableModel(content, headers);
174        final GpxTrackTable t = new GpxTrackTable(content, model);
175        // define how to sort row
176        TableRowSorter<DefaultTableModel> rowSorter = new TableRowSorter<>();
177        t.setRowSorter(rowSorter);
178        rowSorter.setModel(model);
179        rowSorter.setComparator(3, new LengthContentComparator());
180        // default column widths
181        t.getColumnModel().getColumn(0).setPreferredWidth(220);
182        t.getColumnModel().getColumn(1).setPreferredWidth(300);
183        t.getColumnModel().getColumn(2).setPreferredWidth(200);
184        t.getColumnModel().getColumn(3).setPreferredWidth(50);
185        t.getColumnModel().getColumn(4).setPreferredWidth(100);
186        // make the link clickable
187        final MouseListener urlOpener = new MouseAdapter() {
188            @Override
189            public void mouseClicked(MouseEvent e) {
190                if (e.getClickCount() != 2) {
191                    return;
192                }
193                JTable t = (JTable) e.getSource();
194                int col = t.convertColumnIndexToModel(t.columnAtPoint(e.getPoint()));
195                if (col != 4) {
196                    return;
197                }
198                int row = t.rowAtPoint(e.getPoint());
199                String url = (String) t.getValueAt(row, col);
200                if (url == null || url.isEmpty()) {
201                    return;
202                }
203                OpenBrowser.displayUrl(url);
204            }
205        };
206        t.addMouseListener(urlOpener);
207        t.setFillsViewportHeight(true);
208        t.putClientProperty("terminateEditOnFocusLost", true);
209        return t;
210    }
211
212    private boolean noUpdates;
213
214    /** selects all rows (=tracks) in the table that are currently visible on the layer*/
215    private void selectVisibleTracksInTable() {
216        // don't select any tracks if the layer is not visible
217        if (!layer.isVisible()) {
218            return;
219        }
220        ListSelectionModel s = table.getSelectionModel();
221        s.setValueIsAdjusting(true);
222        s.clearSelection();
223        for (int i = 0; i < layer.trackVisibility.length; i++) {
224            if (layer.trackVisibility[i]) {
225                s.addSelectionInterval(i, i);
226            }
227        }
228        s.setValueIsAdjusting(false);
229    }
230
231    /** listens to selection changes in the table and redraws the map */
232    private void listenToSelectionChanges() {
233        table.getSelectionModel().addListSelectionListener(e -> {
234            if (noUpdates || !(e.getSource() instanceof ListSelectionModel)) {
235                return;
236            }
237            updateVisibilityFromTable();
238        });
239    }
240
241    private void updateVisibilityFromTable() {
242        ListSelectionModel s = table.getSelectionModel();
243        for (int i = 0; i < layer.trackVisibility.length; i++) {
244            layer.trackVisibility[table.convertRowIndexToModel(i)] = s.isSelectedIndex(i);
245        }
246        layer.invalidate();
247    }
248
249    @Override
250    public void actionPerformed(ActionEvent ae) {
251        final JPanel msg = new JPanel(new GridBagLayout());
252
253        dateFilter = new DateFilterPanel(layer, "gpx.traces", false);
254        dateFilter.setFilterAppliedListener(e -> {
255            noUpdates = true;
256            selectVisibleTracksInTable();
257            noUpdates = false;
258            layer.invalidate();
259        });
260        dateFilter.loadFromPrefs();
261
262        final JToggleButton b = new JToggleButton(new AbstractAction(tr("Select by date")) {
263            @Override public void actionPerformed(ActionEvent e) {
264                if (((JToggleButton) e.getSource()).isSelected()) {
265                    dateFilter.setEnabled(true);
266                    dateFilter.applyFilter();
267                } else {
268                    dateFilter.setEnabled(false);
269                }
270            }
271        });
272        dateFilter.setEnabled(false);
273        msg.add(b, GBC.std().insets(0, 0, 5, 0));
274        msg.add(dateFilter, GBC.eol().insets(0, 0, 10, 0).fill(GBC.HORIZONTAL));
275
276        msg.add(new JLabel(tr("<html>Select all tracks that you want to be displayed. " +
277                "You can drag select a range of tracks or use CTRL+Click to select specific ones. " +
278                "The map is updated live in the background. Open the URLs by double clicking them, " +
279                "edit name and description by double clicking the cell.</html>")),
280                GBC.eop().fill(GBC.HORIZONTAL));
281        // build table
282        final boolean[] trackVisibilityBackup = layer.trackVisibility.clone();
283        Object[][] content = buildTableContents();
284        table = buildTable(content);
285        selectVisibleTracksInTable();
286        listenToSelectionChanges();
287        // make the table scrollable
288        JScrollPane scrollPane = new JScrollPane(table);
289        msg.add(scrollPane, GBC.eol().fill(GBC.BOTH));
290
291        int v = 1;
292        // build dialog
293        ExtendedDialog ed = new ExtendedDialog(MainApplication.getMainFrame(),
294                tr("Set track visibility for {0}", layer.getName()),
295                tr("Set color for selected tracks..."), tr("Show all"), tr("Show selected only"), tr("Close")) {
296            @Override
297            protected void buttonAction(int buttonIndex, ActionEvent evt) {
298                if (buttonIndex == 0) {
299                    List<IGpxTrack> trks = new ArrayList<>();
300                    for (int i : table.getSelectedRows()) {
301                        Object trk = content[i][5];
302                        if (trk != null && trk instanceof IGpxTrack) {
303                            trks.add((IGpxTrack) trk);
304                        }
305                    }
306                    showColorDialog(trks);
307                } else {
308                    super.buttonAction(buttonIndex, evt);
309                }
310            }
311        };
312        ed.setButtonIcons("colorchooser", "eye", "dialogs/filter", "cancel");
313        ed.setContent(msg, false);
314        ed.setDefaultButton(2);
315        ed.setCancelButton(3);
316        ed.configureContextsensitiveHelp("/Action/ChooseTrackVisibility", true);
317        ed.setRememberWindowGeometry(getClass().getName() + ".geometry",
318                WindowGeometry.centerInWindow(MainApplication.getMainFrame(), new Dimension(1000, 500)));
319        ed.showDialog();
320        dateFilter.saveInPrefs();
321        v = ed.getValue();
322        // cancel for unknown buttons and copy back original settings
323        if (v != 2 && v != 3) {
324            layer.trackVisibility = Arrays.copyOf(trackVisibilityBackup, layer.trackVisibility.length);
325            MainApplication.getMap().repaint();
326            return;
327        }
328        // set visibility (2 = show all, 3 = filter). If no tracks are selected
329        // set all of them visible and...
330        ListSelectionModel s = table.getSelectionModel();
331        final boolean all = v == 2 || s.isSelectionEmpty();
332        for (int i = 0; i < layer.trackVisibility.length; i++) {
333            layer.trackVisibility[table.convertRowIndexToModel(i)] = all || s.isSelectedIndex(i);
334        }
335        // layer has been changed
336        layer.invalidate();
337        // ...sync with layer visibility instead to avoid having two ways to hide everything
338        layer.setVisible(v == 2 || !s.isSelectionEmpty());
339    }
340
341    private static class GpxTrackTable extends JTable {
342        final Object[][] content;
343
344        GpxTrackTable(Object[][] content, TableModel model) {
345            super(model);
346            this.content = content;
347        }
348
349        @Override
350        public Component prepareRenderer(TableCellRenderer renderer, int row, int col) {
351            Component c = super.prepareRenderer(renderer, row, col);
352            if (c instanceof JComponent) {
353                JComponent jc = (JComponent) c;
354                jc.setToolTipText(getValueAt(row, col).toString());
355                if (content.length > row
356                        && content[row].length > 5
357                        && content[row][5] instanceof IGpxTrack) {
358                    Color color = ((IGpxTrack) content[row][5]).getColor();
359                    if (color != null) {
360                        double brightness = Math.sqrt(Math.pow(color.getRed(), 2) * .241
361                                + Math.pow(color.getGreen(), 2) * .691
362                                + Math.pow(color.getBlue(), 2) * .068);
363                        if (brightness > 250) {
364                            color = color.darker();
365                        }
366                        if (isRowSelected(row)) {
367                            jc.setBackground(color);
368                            if (brightness <= 130) {
369                                jc.setForeground(Color.WHITE);
370                            } else {
371                                jc.setForeground(Color.BLACK);
372                            }
373                        } else {
374                            if (brightness > 200) {
375                                color = color.darker(); //brightness >250 is darkened twice on purpose
376                            }
377                            jc.setForeground(color);
378                            jc.setBackground(Color.WHITE);
379                        }
380                    } else {
381                        jc.setForeground(Color.BLACK);
382                        if (isRowSelected(row)) {
383                            jc.setBackground(new Color(175, 210, 210));
384                        } else {
385                            jc.setBackground(Color.WHITE);
386                        }
387                    }
388                }
389            }
390            return c;
391        }
392
393        @Override
394        public boolean isCellEditable(int rowIndex, int colIndex) {
395            return colIndex <= 1;
396        }
397
398        @Override
399        public void tableChanged(TableModelEvent e) {
400            super.tableChanged(e);
401            int col = e.getColumn();
402            int row = e.getFirstRow();
403            if (row >= 0 && row < content.length && col >= 0 && col <= 1) {
404                Object t = content[row][5];
405                String val = (String) getValueAt(row, col);
406                if (t != null && t instanceof IGpxTrack) {
407                    IGpxTrack trk = (IGpxTrack) t;
408                    if (col == 0) {
409                        trk.put("name", val);
410                    } else {
411                        trk.put("desc", val);
412                    }
413                } else {
414                    throw new InvalidArgumentException("Invalid object in table, must be IGpxTrack.");
415                }
416            }
417        }
418    }
419}