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