1 /* SPDX-FileCopyrightText: 2020-2021 Tobias Leupold <tobias.leupold@gmx.de>
2 
3    SPDX-License-Identifier: GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
4 */
5 
6 // Local includes
7 #include "GpxEngine.h"
8 #include "GeoDataModel.h"
9 
10 // Marble includes
11 #include <marble/GeoDataCoordinates.h>
12 
13 // Qt includes
14 #include <QDebug>
15 #include <QFile>
16 #include <QXmlStreamReader>
17 #include <QJsonDocument>
18 #include <QStandardPaths>
19 #include <QFile>
20 
21 // C++ includes
22 #include <cmath>
23 
24 static const auto s_gpx    = QStringLiteral("gpx");
25 static const auto s_trk    = QStringLiteral("trk");
26 static const auto s_trkpt  = QStringLiteral("trkpt");
27 static const auto s_lon    = QStringLiteral("lon");
28 static const auto s_lat    = QStringLiteral("lat");
29 static const auto s_ele    = QStringLiteral("ele");
30 static const auto s_time   = QStringLiteral("time");
31 static const auto s_trkseg = QStringLiteral("trkseg");
32 
GpxEngine(QObject * parent,GeoDataModel * geoDataModel)33 GpxEngine::GpxEngine(QObject *parent, GeoDataModel *geoDataModel)
34     : QObject(parent),
35       m_geoDataModel(geoDataModel)
36 {
37     // Load the timezone map image
38     const auto timezoneMapFile = QStandardPaths::locate(QStandardPaths::AppDataLocation,
39                                                         QStringLiteral("timezones.png"));
40     if (! timezoneMapFile.isEmpty()) {
41         m_timezoneMap = QImage(timezoneMapFile);
42     }
43 
44     // Load the color-timezone mapping
45     const auto timezoneMappingFile = QStandardPaths::locate(QStandardPaths::AppDataLocation,
46                                                             QStringLiteral("timezones.json"));
47     if (! timezoneMappingFile.isEmpty()) {
48         QFile jsonData(timezoneMappingFile);
49         if (jsonData.open(QIODevice::ReadOnly | QIODevice::Text)) {
50             const auto jsonDocument = QJsonDocument::fromJson(jsonData.readAll());
51             jsonData.close();
52             m_timezoneMapping = jsonDocument.object();
53         }
54     }
55 }
56 
load(const QString & path)57 GpxEngine::LoadInfo GpxEngine::load(const QString &path)
58 {
59     if (m_geoDataModel->contains(path)) {
60         return { LoadResult::AlreadyLoaded };
61     }
62 
63     QFile gpxFile(path);
64 
65     if (! gpxFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
66         return { LoadResult::OpenFailed };
67     }
68 
69     QXmlStreamReader xml(&gpxFile);
70 
71     double lon = 0.0;
72     double lat = 0.0;
73     double alt = 0.0;
74     QDateTime time;
75 
76     QVector<QDateTime> segmentTimes;
77     QVector<Coordinates> segmentCoordinates;
78 
79     QVector<QVector<Coordinates>> allSegments;
80     QVector<QVector<QDateTime>> allSegmentTimes;
81 
82     bool gpxFound = false;
83     bool trackStartFound = false;
84 
85     int tracks = 0;
86     int segments = 0;
87     int points = 0;
88 
89     while (! xml.atEnd()) {
90         if (xml.hasError()) {
91             return { LoadResult::XmlError };
92         }
93 
94         const QXmlStreamReader::TokenType token = xml.readNext();
95         const QStringRef name = xml.name();
96 
97         if (token == QXmlStreamReader::StartElement) {
98             if (! gpxFound) {
99                 if (name != s_gpx) {
100                     continue;
101                 } else {
102                     gpxFound = true;
103                 }
104             }
105 
106             if (! trackStartFound) {
107                 if (name != s_trk) {
108                     continue;
109                 } else {
110                     trackStartFound = true;
111                     tracks++;
112                 }
113             }
114 
115             if (name == s_trkseg) {
116                 segments++;
117 
118             } else if (name == s_trkpt) {
119                 QXmlStreamAttributes attributes = xml.attributes();
120                 lon = attributes.value(s_lon).toDouble();
121                 lat = attributes.value(s_lat).toDouble();
122                 points++;
123 
124             } else if (name == s_ele) {
125                 xml.readNext();
126                 alt = xml.text().toDouble();
127 
128             } else if (name == s_time) {
129                 xml.readNext();
130                 time = QDateTime::fromString(xml.text().toString(), Qt::ISODate);
131 
132                 // Strip out milliseconds if the GPX provides them to allow seconds-exact matching
133                 const auto msec = time.time().msec();
134                 if (msec != 0) {
135                     time = time.addMSecs(msec * -1);
136                 }
137             }
138 
139         } else if (token == QXmlStreamReader::EndElement) {
140             if (name == s_trkpt) {
141                 segmentTimes.append(time);
142                 segmentCoordinates.append(Coordinates(lon, lat, alt, true));
143                 alt = 0.0;
144                 time = QDateTime();
145 
146             } else if (name == s_trkseg && ! segmentCoordinates.isEmpty()) {
147                 allSegmentTimes.append(segmentTimes);
148                 allSegments.append(segmentCoordinates);
149                 segmentTimes.clear();
150                 segmentCoordinates.clear();
151 
152             } else if (name == s_trk) {
153                 trackStartFound = false;
154             }
155         }
156     }
157 
158     if (! gpxFound) {
159         return { LoadResult::NoGpxElement, tracks, segments, points };
160     }
161 
162     if (points == 0) {
163         return { LoadResult::NoGeoData, tracks, segments, points };
164     }
165 
166     // All okay :-)
167 
168     // Pass the loaded data to the GeoDataModel
169     m_geoDataModel->addTrack(path, allSegmentTimes, allSegments);
170 
171     // Detect the presumable timezone the corresponding photos were taken in
172 
173     const double width = m_timezoneMap.size().width();
174     const double height = m_timezoneMap.size().height();
175 
176     // Get the loaded path's bounding box's center point
177     const auto trackCenter = m_geoDataModel->trackBoxCenter(path);
178 
179     // Scale the coordinates to the image size, relative to the image center
180     int mappedLon = std::round(trackCenter.lon() / 180.0 * (width / 2.0));
181     int mappedLat = std::round(trackCenter.lat() / 90.0 * (height / 2.0));
182 
183     // Move the mapped coordinates to the left lower edge
184     mappedLon = width / 2 + mappedLon;
185     mappedLat = height - (height / 2 + mappedLat);
186 
187     // Get the respective pixel's color
188     const auto timezoneColor = m_timezoneMap.pixelColor(mappedLon, mappedLat).name();
189 
190     // Lookup the corresponding timezone
191     const auto timezoneId = m_timezoneMapping.value(timezoneColor);
192     if (timezoneId.isString()) {
193         m_lastDetectedTimeZoneId = timezoneId.toString().toUtf8();
194     } else {
195         m_lastDetectedTimeZoneId.clear();
196     }
197 
198     return { LoadResult::Okay, tracks, segments, points };
199 }
200 
setMatchParameters(int exactMatchTolerance,int maximumInterpolationInterval,int maximumInterpolationDistance)201 void GpxEngine::setMatchParameters(int exactMatchTolerance, int maximumInterpolationInterval,
202                                    int maximumInterpolationDistance)
203 {
204     m_exactMatchTolerance = exactMatchTolerance;
205     m_maximumInterpolationInterval = maximumInterpolationInterval;
206     m_maximumInterpolationDistance = maximumInterpolationDistance;
207 }
208 
findExactCoordinates(const QDateTime & time,int deviation) const209 Coordinates GpxEngine::findExactCoordinates(const QDateTime &time, int deviation) const
210 {
211     if (deviation == 0) {
212         return findExactCoordinates(time);
213     } else {
214         const QDateTime fixedTime = time.addSecs(deviation);
215         return findExactCoordinates(fixedTime);
216     }
217 }
218 
findExactCoordinates(const QDateTime & time) const219 Coordinates GpxEngine::findExactCoordinates(const QDateTime &time) const
220 {
221     // Iterate over all loaded files we have
222     for (const auto &trackPoints : m_geoDataModel->trackPoints()) {
223         // Check for an exact match
224         if (trackPoints.contains(time)) {
225             return trackPoints.value(time);
226         }
227 
228         // Check for a match with +/- the maximum tolerable deviation
229         for (int i = 1; i <= m_exactMatchTolerance; i++) {
230             const auto timeBefore = time.addSecs(i * -1);
231             if (trackPoints.contains(timeBefore)) {
232                 return trackPoints.value(timeBefore);
233             }
234             const auto timeAfter = time.addSecs(i);
235             if (trackPoints.contains(timeAfter)) {
236                 return trackPoints.value(timeAfter);
237             }
238         }
239     }
240 
241     // No match found
242     return Coordinates();
243 }
244 
findInterpolatedCoordinates(const QDateTime & time,int deviation) const245 Coordinates GpxEngine::findInterpolatedCoordinates(const QDateTime &time, int deviation) const
246 {
247     if (deviation == 0) {
248         return findInterpolatedCoordinates(time);
249     } else {
250         const QDateTime fixedTime = time.addSecs(deviation);
251         return findInterpolatedCoordinates(fixedTime);
252     }
253 }
254 
findInterpolatedCoordinates(const QDateTime & time) const255 Coordinates GpxEngine::findInterpolatedCoordinates(const QDateTime &time) const
256 {
257     // Iterate over all loaded files we have
258     for (int i = 0; i < m_geoDataModel->dateTimes().count(); i++) {
259         const auto &dateTimes = m_geoDataModel->dateTimes().at(i);
260         const auto &trackPoints = m_geoDataModel->trackPoints().at(i);
261 
262         // This only works if we at least have at least 2 points ;-)
263         if (dateTimes.count() < 2) {
264             continue;
265         }
266 
267         // If the image's date is before the first or after the last point we have,
268         // it can't be assigned.
269         if (time < dateTimes.first() || time > dateTimes.last()) {
270             continue;
271         }
272 
273         // Check for an exact match (without tolerance)
274         // This also eliminates the case that the time could be the first one.
275         // We thus can be sure the first entry in dateTimes is earlier than the time requested.
276         if (dateTimes.contains(time)) {
277             return trackPoints.value(time);
278         }
279 
280         // Search for the first time earlier than the image's
281 
282         int start = 0;
283         int end = dateTimes.count() - 1;
284         int index = 0;
285         int lastIndex = -1;
286 
287         while (true) {
288             index = start + (end - start) / 2;
289             if (index == lastIndex) {
290                 break;
291             }
292 
293             if (dateTimes.at(index) > time) {
294                 end = index;
295             } else {
296                 start = index;
297             }
298 
299             lastIndex = index;
300         }
301 
302         // If the found point is the last one, we can't interpolate and use it directly
303         const auto &closestBefore = dateTimes.at(index);
304         if (closestBefore == dateTimes.last()) {
305             return trackPoints.value(closestBefore);
306         }
307 
308         // Interpolate between the two coordinates
309 
310         const auto &closestAfter = dateTimes.at(index + 1);
311 
312         // Check for a maximum time interval between the points if requested
313         if (m_maximumInterpolationInterval != -1
314             && closestBefore.secsTo(closestAfter) > m_maximumInterpolationInterval) {
315 
316             continue;
317         }
318 
319         // Create Marble coordinates from the cache for further calculations
320         const auto &pointBefore = trackPoints[closestBefore];
321         const auto &pointAfter = trackPoints[closestAfter];
322         const auto coordinatesBefore = Marble::GeoDataCoordinates(
323             pointBefore.lon(), pointBefore.lat(), pointBefore.alt(),
324             Marble::GeoDataCoordinates::Degree);
325         const auto coordinatesAfter = Marble::GeoDataCoordinates(
326             pointAfter.lon(), pointAfter.lat(), pointAfter.alt(),
327             Marble::GeoDataCoordinates::Degree);
328 
329         // Check for a maximum distance between the points if requested
330 
331         if (m_maximumInterpolationDistance != -1
332             && coordinatesBefore.sphericalDistanceTo(coordinatesAfter) * KGeoTag::earthRadius
333             > m_maximumInterpolationDistance) {
334 
335             continue;
336         }
337 
338         // Calculate an interpolated position between the coordinates
339 
340         const int secondsBefore = closestBefore.secsTo(time);
341         const double fraction = double(secondsBefore)
342                                 / double(secondsBefore + time.secsTo(closestAfter));
343         const auto interpolated = coordinatesBefore.interpolate(coordinatesAfter, fraction);
344 
345         return Coordinates(interpolated.longitude(Marble::GeoDataCoordinates::Degree),
346                         interpolated.latitude(Marble::GeoDataCoordinates::Degree),
347                         interpolated.altitude(),
348                         true);
349     }
350 
351     // No match found
352     return Coordinates();
353 }
354 
lastDetectedTimeZoneId() const355 QByteArray GpxEngine::lastDetectedTimeZoneId() const
356 {
357     return m_lastDetectedTimeZoneId;
358 }
359 
timeZoneDataLoaded() const360 bool GpxEngine::timeZoneDataLoaded() const
361 {
362     return ! m_timezoneMap.isNull() && ! m_timezoneMapping.isEmpty();
363 }
364