001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.gpx;
003
004import java.awt.Color;
005import java.util.ArrayList;
006import java.util.Collection;
007import java.util.Collections;
008import java.util.HashMap;
009import java.util.List;
010import java.util.Map;
011import java.util.Map.Entry;
012import java.util.Optional;
013
014import org.openstreetmap.josm.data.Bounds;
015import org.openstreetmap.josm.tools.ListenerList;
016import org.openstreetmap.josm.tools.Logging;
017
018/**
019 * GPX track.
020 * Note that the color attributes are not immutable and may be modified by the user.
021 * @since 15496
022 */
023public class GpxTrack extends WithAttributes implements IGpxTrack {
024
025    private final List<IGpxTrackSegment> segments;
026    private final double length;
027    private final Bounds bounds;
028    private Color colorCache;
029    private final ListenerList<IGpxTrack.GpxTrackChangeListener> listeners = ListenerList.create();
030    private static final HashMap<Color, String> closestGarminColorCache = new HashMap<>();
031    private ColorFormat colorFormat;
032
033    /**
034     * Constructs a new {@code GpxTrack}.
035     * @param trackSegs track segments
036     * @param attributes track attributes
037     */
038    public GpxTrack(Collection<Collection<WayPoint>> trackSegs, Map<String, Object> attributes) {
039        List<IGpxTrackSegment> newSegments = new ArrayList<>();
040        for (Collection<WayPoint> trackSeg: trackSegs) {
041            if (trackSeg != null && !trackSeg.isEmpty()) {
042                newSegments.add(new GpxTrackSegment(trackSeg));
043            }
044        }
045        this.segments = Collections.unmodifiableList(newSegments);
046        this.length = calculateLength();
047        this.bounds = calculateBounds();
048        this.attr = new HashMap<>(attributes);
049    }
050
051    /**
052     * Constructs a new {@code GpxTrack} from {@code GpxTrackSegment} objects.
053     * @param trackSegs The segments to build the track from.  Input is not deep-copied,
054     *                 which means the caller may reuse the same segments to build
055     *                 multiple GpxTrack instances from.  This should not be
056     *                 a problem, since this object cannot modify {@code this.segments}.
057     * @param attributes Attributes for the GpxTrack, the input map is copied.
058     */
059    public GpxTrack(List<IGpxTrackSegment> trackSegs, Map<String, Object> attributes) {
060        this.attr = new HashMap<>(attributes);
061        this.segments = Collections.unmodifiableList(trackSegs);
062        this.length = calculateLength();
063        this.bounds = calculateBounds();
064    }
065
066    private double calculateLength() {
067        double result = 0.0; // in meters
068
069        for (IGpxTrackSegment trkseg : segments) {
070            result += trkseg.length();
071        }
072        return result;
073    }
074
075    private Bounds calculateBounds() {
076        Bounds result = null;
077        for (IGpxTrackSegment segment: segments) {
078            Bounds segBounds = segment.getBounds();
079            if (segBounds != null) {
080                if (result == null) {
081                    result = new Bounds(segBounds);
082                } else {
083                    result.extend(segBounds);
084                }
085            }
086        }
087        return result;
088    }
089
090    @Override
091    public void setColor(Color color) {
092        setColorExtension(color);
093        colorCache = color;
094    }
095
096    private void setColorExtension(Color color) {
097        getExtensions().findAndRemove("gpxx", "DisplayColor");
098        if (color == null) {
099            getExtensions().findAndRemove("gpxd", "color");
100        } else {
101            getExtensions().addOrUpdate("gpxd", "color", String.format("#%02X%02X%02X", color.getRed(), color.getGreen(), color.getBlue()));
102        }
103        fireInvalidate();
104    }
105
106    @Override
107    public Color getColor() {
108        if (colorCache == null) {
109            colorCache = getColorFromExtension();
110        }
111        return colorCache;
112    }
113
114    private Color getColorFromExtension() {
115        GpxExtension gpxd = getExtensions().find("gpxd", "color");
116        if (gpxd != null) {
117            colorFormat = ColorFormat.GPXD;
118            String cs = gpxd.getValue();
119            try {
120                return Color.decode(cs);
121            } catch (NumberFormatException ex) {
122                Logging.warn("Could not read gpxd color: " + cs);
123            }
124        } else {
125            GpxExtension gpxx = getExtensions().find("gpxx", "DisplayColor");
126            if (gpxx != null) {
127                colorFormat = ColorFormat.GPXX;
128                String cs = gpxx.getValue();
129                if (cs != null) {
130                    Color cc = GARMIN_COLORS.get(cs);
131                    if (cc != null) {
132                        return cc;
133                    }
134                }
135                Logging.warn("Could not read garmin color: " + cs);
136            }
137        }
138        return null;
139    }
140
141    /**
142     * Converts the color to the given format, if present.
143     * @param cFormat can be a {@link GpxConstants.ColorFormat}
144     */
145    public void convertColor(ColorFormat cFormat) {
146        Color c = getColor();
147        if (c == null) return;
148
149        if (cFormat != this.colorFormat) {
150            if (cFormat == null) {
151                // just hide the extensions, don't actually remove them
152                Optional.ofNullable(getExtensions().find("gpxx", "DisplayColor")).ifPresent(GpxExtension::hide);
153                Optional.ofNullable(getExtensions().find("gpxd", "color")).ifPresent(GpxExtension::hide);
154            } else if (cFormat == ColorFormat.GPXX) {
155                getExtensions().findAndRemove("gpxd", "color");
156                String colorString = null;
157                if (closestGarminColorCache.containsKey(c)) {
158                    colorString = closestGarminColorCache.get(c);
159                } else {
160                    //find closest garmin color
161                    double closestDiff = -1;
162                    for (Entry<String, Color> e : GARMIN_COLORS.entrySet()) {
163                        double diff = colorDist(e.getValue(), c);
164                        if (closestDiff < 0 || diff < closestDiff) {
165                            colorString = e.getKey();
166                            closestDiff = diff;
167                            if (closestDiff == 0) break;
168                        }
169                    }
170                }
171                closestGarminColorCache.put(c, colorString);
172                getExtensions().addIfNotPresent("gpxx", "TrackExtensions").getExtensions().addOrUpdate("gpxx", "DisplayColor", colorString);
173            } else if (cFormat == ColorFormat.GPXD) {
174                setColor(c);
175            }
176            colorFormat = cFormat;
177        }
178    }
179
180    private double colorDist(Color c1, Color c2) {
181        // Simple Euclidean distance between two colors
182        return Math.sqrt(Math.pow(c1.getRed() - c2.getRed(), 2)
183                + Math.pow(c1.getGreen() - c2.getGreen(), 2)
184                + Math.pow(c1.getBlue() - c2.getBlue(), 2));
185    }
186
187    @Override
188    public void put(String key, Object value) {
189        super.put(key, value);
190        fireInvalidate();
191    }
192
193    private void fireInvalidate() {
194        listeners.fireEvent(l -> l.gpxDataChanged(new IGpxTrack.GpxTrackChangeEvent(this)));
195    }
196
197    @Override
198    public Bounds getBounds() {
199        return bounds == null ? null : new Bounds(bounds);
200    }
201
202    @Override
203    public double length() {
204        return length;
205    }
206
207    @Override
208    public Collection<IGpxTrackSegment> getSegments() {
209        return segments;
210    }
211
212    @Override
213    public int hashCode() {
214        return 31 * super.hashCode() + ((segments == null) ? 0 : segments.hashCode());
215    }
216
217    @Override
218    public boolean equals(Object obj) {
219        if (this == obj)
220            return true;
221        if (obj == null)
222            return false;
223        if (!super.equals(obj))
224            return false;
225        if (getClass() != obj.getClass())
226            return false;
227        GpxTrack other = (GpxTrack) obj;
228        if (segments == null) {
229            if (other.segments != null)
230                return false;
231        } else if (!segments.equals(other.segments))
232            return false;
233        return true;
234    }
235
236    @Override
237    public void addListener(IGpxTrack.GpxTrackChangeListener l) {
238        listeners.addListener(l);
239    }
240
241    @Override
242    public void removeListener(IGpxTrack.GpxTrackChangeListener l) {
243        listeners.removeListener(l);
244    }
245
246    /**
247     * Resets the color cache
248     */
249    public void invalidate() {
250        colorCache = null;
251    }
252
253    /**
254     * A listener that listens to GPX track changes.
255     * @deprecated use {@link IGpxTrack.GpxTrackChangeListener} instead
256     */
257    @Deprecated
258    @FunctionalInterface
259    interface GpxTrackChangeListener {
260        void gpxDataChanged(GpxTrackChangeEvent e);
261    }
262
263    /**
264     * A track change event for the current track.
265     * @deprecated use {@link IGpxTrack.GpxTrackChangeEvent} instead
266     */
267    @Deprecated
268    static class GpxTrackChangeEvent extends IGpxTrack.GpxTrackChangeEvent {
269        GpxTrackChangeEvent(IGpxTrack source) {
270            super(source);
271        }
272    }
273
274}