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