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