QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
LogViewerChart.qml
Go to the documentation of this file.
1import QtQuick
2import QtQuick.Controls
3import QtQuick.Layouts
4import QtGraphs
5
6import QGroundControl
7import QGroundControl.Controls
8
9/// Self-contained chart view for the Log Viewer.
10/// Sizing and visibility are controlled entirely by the parent.
11/// The parent calls refreshBinChart(), centerCursor(), and clearMarker() at
12/// appropriate lifecycle points (log load, log clear, field-clear).
13ColumnLayout {
14 id: control
15
16 required property var logParser
17 required property var logViewerController
18
19 property bool xAxisShowLocalTime: false
20
21 spacing: ScreenTools.defaultFontPixelHeight * 0.5
22
23 // -------------------------------------------------------------------------
24 // Internal state
25 // -------------------------------------------------------------------------
26 property var _markerRows: []
27 property var _markerEventRows: []
28 property string _markerModeName: ""
29
30 property var _seriesByField: ({})
31 property var _fieldYRange: ({})
32 property var _fieldFullRange: ({}) // full-dataset min/max, set when series is first created
33 property var _eventSeriesByType: ({})
34
35 // Shared position / zoom signals
36 signal cursorMoved(real timestampSeconds)
37 signal zoomApplied(real minX, real maxX)
38
39
40 readonly property real _legendRowHeight: ScreenTools.defaultFontPixelHeight * 1.1
41 readonly property real _legendColorBlockSize: ScreenTools.defaultFontPixelHeight * 0.8
42 readonly property real _legendItemSpacing: ScreenTools.defaultFontPixelWidth * 0.2
43
44 readonly property var _fieldChartColors: [
45 "#1E88E5", // blue
46 "#E53935", // red
47 "#43A047", // green
48 "#FB8C00", // orange
49 "#8E24AA", // purple
50 "#00ACC1", // cyan
51 "#FDD835", // yellow
52 "#D81B60", // pink
53 "#6D4C41", // brown
54 "#546E7A", // blue grey
55 "#3949AB", // indigo
56 "#00897B", // teal
57 ]
58
59 // -------------------------------------------------------------------------
60 // Helper components
61 // -------------------------------------------------------------------------
62 Component {
63 id: _lineSeriesComponent
64 LineSeries { }
65 }
66
67 Component {
68 id: _scatterSeriesComponent
69 ScatterSeries { }
70 }
71
72 // -------------------------------------------------------------------------
73 // Color helpers (public — parent may call fieldColor() for the field list)
74 // -------------------------------------------------------------------------
75 function eventColor(eventType) {
76 return logViewerController.eventColor(eventType)
77 }
78
79 function modeColor(modeName) {
80 return logParser.modeColor(String(modeName))
81 }
82
83 function modeLegendEntries() {
84 return logParser.modeNames
85 }
86
87 function fieldColor(fieldName) {
88 const idx = logViewerController.selectedFields.indexOf(fieldName)
89 if (idx >= 0) {
90 return _fieldChartColors[idx % _fieldChartColors.length]
91 }
92 return logViewerController.fieldColor(fieldName)
93 }
94
95 function eventTypeLabel(eventType) {
96 if (eventType === "mode") return qsTr("Mode")
97 if (eventType === "event") return qsTr("Event")
98 if (eventType === "error") return qsTr("Error")
99 if (eventType === "warning") return qsTr("Warning")
100 return eventType
101 }
102
103 // -------------------------------------------------------------------------
104 // Zoom / cursor (delegated to _base)
105 // -------------------------------------------------------------------------
106 function applyZoomRange(minX, maxX) { _base.applyZoomRange(minX, maxX) }
107 function setSharedZoom(minX, maxX) { _base.setSharedZoom(minX, maxX) }
108 function setSharedCursor(t) { _base.setSharedCursor(t) }
109 function resetZoom() { _base.resetZoom() }
110
111 // -------------------------------------------------------------------------
112 // Marker / cursor
113 // -------------------------------------------------------------------------
114 function _queryValuesAtTime(xValue) {
115 const modeName = logParser.modeAt(xValue)
116 const selectedFields = logViewerController.selectedFields
117 const rows = []
118 for (let i = 0; i < selectedFields.length; i++) {
119 const field = selectedFields[i]
120 const value = logParser.fieldValueAt(field, xValue)
121 if (isNaN(value)) continue
122 const fr = _fieldFullRange[field]
123 rows.push({
124 name: field,
125 color: fieldColor(field),
126 value: value,
127 min: fr ? fr.min : NaN,
128 max: fr ? fr.max : NaN
129 })
130 }
131 const threshold = Math.max(0.05, (_base.xAxis.max - _base.xAxis.min) / 200.0)
132 const nearbyEvents = logParser.eventsNear(xValue, threshold)
133 const events = []
134 for (let i = 0; i < nearbyEvents.length; i++) {
135 events.push({ color: eventColor(nearbyEvents[i].type), text: nearbyEvents[i].description })
136 }
137 return { modeName: modeName, rows: rows, events: events }
138 }
139
140 function _queryCursorValues() {
141 const result = _queryValuesAtTime(_base.markerXValue)
142 _markerModeName = result.modeName
143 _markerRows = result.rows
144 _markerEventRows = result.events
145 }
146
147 // Public: called by parent after log load
148 function centerCursor() {
149 if (_base.xAxis.max <= _base.xAxis.min) return
150 _base.setCursor((_base.xAxis.min + _base.xAxis.max) / 2)
151 }
152
153 // Public: called by parent on log clear
154 function clearMarker() {
155 _base.clearCursor()
156 _markerModeName = ""
157 _markerRows = []
158 _markerEventRows = []
159 }
160
161 // -------------------------------------------------------------------------
162 // Chart / series management
163 // -------------------------------------------------------------------------
164
165 // Public: full reset — called by parent on log load or clear
166 function refreshBinChart() {
167 while (_base.graphsView.seriesList.length > 0) {
168 _base.graphsView.removeSeries(_base.graphsView.seriesList[0])
169 }
170 _seriesByField = {}
171 _fieldYRange = {}
172 _fieldFullRange = {}
173 _eventSeriesByType = {}
174
175 const hasRange = logParser.minTimestamp >= 0.0 && logParser.maxTimestamp > logParser.minTimestamp
176 _base.initRange(hasRange ? logParser.minTimestamp : 0,
177 hasRange ? logParser.maxTimestamp : 1)
178 _base.yAxis.min = 0
179 _base.yAxis.max = 1
180 }
181
182 function _syncSeriesWithSelection() {
183 const newSelection = logViewerController.selectedFields
184
185 // Remove series no longer in selection
186 const desired = {}
187 for (let i = 0; i < newSelection.length; i++) {
188 desired[String(newSelection[i])] = true
189 }
190 const tracked = Object.keys(_seriesByField)
191 for (let i = 0; i < tracked.length; i++) {
192 if (!desired[tracked[i]]) {
193 _base.graphsView.removeSeries(_seriesByField[tracked[i]])
194 delete _seriesByField[tracked[i]]
195 delete _fieldYRange[tracked[i]]
196 }
197 }
198
199 // Rebuild series data for the current zoom window.
200 // Reuse existing series objects (clear + repopulate) to avoid Qt Graphs
201 // lifecycle issues that occur when a series is removed and immediately recreated.
202 for (let i = 0; i < newSelection.length; i++) {
203 const fieldName = String(newSelection[i])
204
205 const pixelWidth = Math.max(1, Math.floor(_base.graphsView.plotArea.width))
206 const points = logParser.fieldSamplesFiltered(fieldName, _base.zoomMinX, _base.zoomMaxX, pixelWidth)
207
208 let series
209 if (_seriesByField[fieldName]) {
210 series = _seriesByField[fieldName]
211 series.clear()
212 } else {
213 series = _lineSeriesComponent.createObject(_base.graphsView, {
214 color: fieldColor(fieldName),
215 width: 2,
216 axisX: _base.xAxis,
217 axisY: _base.yAxis
218 })
219 _base.graphsView.addSeries(series)
220 _seriesByField[fieldName] = series
221
222 // Compute full-dataset min/max once when the series is first created
223 const fr = logParser.fieldMinMax(fieldName)
224 _fieldFullRange[fieldName] = (fr && fr.min !== undefined && fr.min <= fr.max) ? { min: fr.min, max: fr.max } : null
225 }
226
227 if (!points || points.length === 0) {
228 _fieldYRange[fieldName] = { min: 0, max: 1 }
229 continue
230 }
231
232 let minY = Number.MAX_VALUE
233 let maxY = -Number.MAX_VALUE
234 for (let j = 0; j < points.length; j++) {
235 series.append(points[j].x, points[j].y)
236 if (points[j].y < minY) minY = points[j].y
237 if (points[j].y > maxY) maxY = points[j].y
238 }
239 _fieldYRange[fieldName] = { min: minY, max: maxY }
240 }
241
242 for (let i = 0; i < newSelection.length; i++) {
243 const fn = String(newSelection[i])
244 const s = _seriesByField[fn]
245 if (s) s.color = fieldColor(fn)
246 }
247
248 const allTracked = Object.keys(_seriesByField)
249 let globalMinY = 0
250 let globalMaxY = 1
251 if (allTracked.length > 0) {
252 globalMinY = Number.MAX_VALUE
253 globalMaxY = -Number.MAX_VALUE
254 for (let i = 0; i < allTracked.length; i++) {
255 const r = _fieldYRange[allTracked[i]]
256 if (r) {
257 if (r.min < globalMinY) globalMinY = r.min
258 if (r.max > globalMaxY) globalMaxY = r.max
259 }
260 }
261 if (globalMinY === globalMaxY) globalMaxY = globalMinY + 1
262 }
263 _base.yAxis.min = globalMinY
264 _base.yAxis.max = globalMaxY
265
266 const existingTypes = Object.keys(_eventSeriesByType)
267 for (let i = 0; i < existingTypes.length; i++) {
268 _base.graphsView.removeSeries(_eventSeriesByType[existingTypes[i]])
269 }
270 _eventSeriesByType = {}
271 const eventList = logParser.events
272 for (let e = 0; e < eventList.length; e++) {
273 const ev = eventList[e]
274 if (ev.time < _base.xAxis.min || ev.time > _base.xAxis.max) continue
275 if (!_eventSeriesByType[ev.type]) {
276 const eventSeries = _scatterSeriesComponent.createObject(_base.graphsView, {
277 color: eventColor(ev.type),
278 axisX: _base.xAxis,
279 axisY: _base.yAxis
280 })
281 _base.graphsView.addSeries(eventSeries)
282 _eventSeriesByType[ev.type] = eventSeries
283 }
284 _eventSeriesByType[ev.type].append(ev.time, _base.yAxis.max)
285 }
286
287 if (_base.markerVisible) {
288 Qt.callLater(_queryCursorValues)
289 }
290 }
291
292 // -------------------------------------------------------------------------
293 // Internal connections
294 // -------------------------------------------------------------------------
295 Connections {
296 target: logViewerController
297 function onSelectedFieldsChanged() {
298 Qt.callLater(_syncSeriesWithSelection)
299 }
300 }
301
302 Connections {
303 target: _base
304 function onZoomRangeSet(minX, maxX) { Qt.callLater(_syncSeriesWithSelection) }
305 function onCursorPositionSet(t) { _queryCursorValues() }
306 function onCursorMoved(t) { cursorMoved(t) }
307 function onZoomApplied(minX, maxX) { zoomApplied(minX, maxX) }
308 }
309
310 // -------------------------------------------------------------------------
311 // Timeline bars (offset to align with chart plot area)
312 // -------------------------------------------------------------------------
313 ColumnLayout {
314 id: _timelineContainer
315 Layout.preferredWidth: _base.graphsView.plotArea.x + _base.graphsView.plotArea.width
316 spacing: ScreenTools.defaultFontPixelHeight * 0.1
317
318 property real _barHeight: ScreenTools.defaultFontPixelHeight * 0.6
319
320 function _segmentX(start) {
321 const w = _base.graphsView.plotArea.width
322 if (_base.xAxis.max <= _base.xAxis.min) return 0
323 return Math.max(0, Math.min(w, ((Math.max(start, _base.xAxis.min) - _base.xAxis.min) / (_base.xAxis.max - _base.xAxis.min)) * w))
324 }
325
326 function _segmentWidth(start, end) {
327 const w = _base.graphsView.plotArea.width
328 if (_base.xAxis.max <= _base.xAxis.min) return 0
329 return ((Math.min(end, _base.xAxis.max) - Math.max(start, _base.xAxis.min)) / (_base.xAxis.max - _base.xAxis.min)) * w
330 }
331
332 function _eventX(time, itemWidth) {
333 const w = _base.graphsView.plotArea.width
334 if (_base.xAxis.max <= _base.xAxis.min) return 0
335 return Math.max(0, Math.min(w - itemWidth, ((time - _base.xAxis.min) / (_base.xAxis.max - _base.xAxis.min)) * w))
336 }
337
338 // Bar 1: Flight modes
339 RowLayout {
340 Layout.fillWidth: true
341 visible: logParser.modeSegments.length > 0
342
343 QGCLabel {
344 text: qsTr("Modes")
345 font.bold: true
346 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
347 Layout.maximumWidth: Layout.preferredWidth
348 }
349
350 Item {
351 Layout.fillWidth: true
352 Layout.preferredHeight: _timelineContainer._barHeight
353
354 Repeater {
355 model: logParser.modeSegments
356
357 Rectangle {
358 visible: _base.xAxis.max > _base.xAxis.min && modelData.end >= _base.xAxis.min && modelData.start <= _base.xAxis.max
359 height: parent.height
360 color: modeColor(modelData.mode)
361 x: _timelineContainer._segmentX(modelData.start)
362 width: Math.max(1, _timelineContainer._segmentWidth(modelData.start, modelData.end))
363 }
364 }
365 }
366 }
367
368 // Bar 2: Dropouts
369 RowLayout {
370 Layout.fillWidth: true
371 visible: logParser.dropouts.length > 0
372
373 QGCLabel {
374 text: qsTr("Dropouts")
375 font.bold: true
376 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
377 Layout.maximumWidth: Layout.preferredWidth
378 }
379
380 Item {
381 Layout.fillWidth: true
382 Layout.preferredHeight: _timelineContainer._barHeight
383
384 Repeater {
385 model: logParser.dropouts
386
387 Rectangle {
388 visible: _base.xAxis.max > _base.xAxis.min && modelData.end >= _base.xAxis.min && modelData.start <= _base.xAxis.max
389 height: parent.height
390 color: Qt.alpha(eventColor("error"), 0.533)
391 x: _timelineContainer._segmentX(modelData.start)
392 width: Math.max(2, _timelineContainer._segmentWidth(modelData.start, modelData.end))
393 }
394 }
395 }
396 }
397
398 // Bar 3: Events
399 RowLayout {
400 Layout.fillWidth: true
401 visible: logParser.events.length > 0
402
403 QGCLabel {
404 text: qsTr("Events")
405 font.bold: true
406 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
407 Layout.maximumWidth: Layout.preferredWidth
408 }
409
410 Item {
411 Layout.fillWidth: true
412 Layout.preferredHeight: _timelineContainer._barHeight
413
414 Repeater {
415 model: logParser.events
416
417 Rectangle {
418 width: 2
419 height: parent.height
420 visible: _base.xAxis.max > _base.xAxis.min
421 color: eventColor(modelData.type)
422 x: _timelineContainer._eventX(modelData.time, width)
423 }
424 }
425 }
426 }
427 }
428
429 // -------------------------------------------------------------------------
430 // Chart area
431 // -------------------------------------------------------------------------
432 LogViewerBaseChart {
433 id: _base
434 Layout.fillWidth: true
435 Layout.fillHeight: true
436 logParser: control.logParser
437 xAxisShowLocalTime: control.xAxisShowLocalTime
438 yAxisTitle: qsTr("Value")
439
440 // ---- Chart-specific popup rows ----
441 RowLayout {
442 visible: _markerModeName.length > 0
443 spacing: ScreenTools.defaultFontPixelWidth * 0.2
444
445 Rectangle {
446 Layout.preferredWidth: _base.colorBlockWidth
447 Layout.preferredHeight: _base.colorBlockWidth
448 color: modeColor(_markerModeName)
449 }
450
451 QGCLabel { text: qsTr("Mode:") }
452 QGCLabel { text: _markerModeName; font.bold: true }
453 }
454
455 Repeater {
456 model: _markerRows
457
458 ColumnLayout {
459 spacing: ScreenTools.defaultFontPixelHeight * 0.15
460
461 RowLayout {
462 spacing: ScreenTools.defaultFontPixelWidth * 0.4
463
464 Rectangle {
465 Layout.preferredWidth: _base.colorBlockWidth
466 Layout.preferredHeight: _base.colorBlockWidth
467 color: modelData.color
468 }
469
470 QGCLabel {
471 width: _base.popupWidth - (ScreenTools.defaultFontPixelWidth * 4)
472 elide: Text.ElideMiddle
473 text: modelData.name
474 font.bold: true
475 }
476 }
477
478 RowLayout {
479 Layout.leftMargin: _base.colorBlockWidth + ScreenTools.defaultFontPixelWidth * 0.4
480 spacing: ScreenTools.defaultFontPixelWidth * 0.3
481
482 QGCLabel { text: qsTr("Current") }
483 QGCLabel { text: Number(modelData.value).toFixed(3); font.bold: true }
484
485 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
486
487 QGCLabel { text: qsTr("Min") }
488 QGCLabel { text: isNaN(modelData.min) ? "—" : Number(modelData.min).toFixed(3); font.bold: true }
489
490 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
491
492 QGCLabel { text: qsTr("Max") }
493 QGCLabel { text: isNaN(modelData.max) ? "—" : Number(modelData.max).toFixed(3); font.bold: true }
494 }
495 }
496 }
497
498 Repeater {
499 model: _markerEventRows
500
501 RowLayout {
502 spacing: ScreenTools.defaultFontPixelWidth * 0.2
503
504 Rectangle {
505 Layout.preferredWidth: _base.colorBlockWidth
506 Layout.preferredHeight: _base.colorBlockWidth
507 color: modelData.color
508 }
509
510 QGCLabel {
511 Layout.maximumWidth: ScreenTools.defaultFontPixelWidth * 20
512 wrapMode: Text.WordWrap
513 maximumLineCount: 2
514 text: modelData.text
515 }
516 }
517 }
518 }
519
520 // -------------------------------------------------------------------------
521 // Legends
522 // -------------------------------------------------------------------------
523 Row {
524 Layout.fillWidth: true
525 Layout.preferredHeight: _legendRowHeight
526 visible: modeLegendEntries().length > 0
527 spacing: ScreenTools.defaultFontPixelWidth
528
529 QGCLabel { text: qsTr("Modes:"); font.bold: true }
530
531 Repeater {
532 model: modeLegendEntries()
533
534 RowLayout {
535 spacing: _legendItemSpacing
536
537 Rectangle {
538 Layout.preferredWidth: _legendColorBlockSize
539 Layout.preferredHeight: _legendColorBlockSize
540 color: modeColor(modelData)
541 }
542
543 QGCLabel { text: modelData }
544 }
545 }
546 }
547
548 Row {
549 Layout.fillWidth: true
550 Layout.preferredHeight: _legendRowHeight
551 visible: logParser.events.length > 0
552 spacing: ScreenTools.defaultFontPixelWidth
553
554 QGCLabel { text: qsTr("Events:"); font.bold: true }
555
556 Repeater {
557 model: ["mode", "event", "error", "warning"]
558
559 RowLayout {
560 spacing: _legendItemSpacing
561 visible: {
562 for (let i = 0; i < logParser.events.length; i++) {
563 if (logParser.events[i].type === modelData) return true
564 }
565 return false
566 }
567
568 Rectangle {
569 Layout.preferredWidth: _legendColorBlockSize
570 Layout.preferredHeight: _legendColorBlockSize
571 color: eventColor(modelData)
572 }
573
574 QGCLabel { text: eventTypeLabel(modelData) }
575 }
576 }
577 }
578
579 // -------------------------------------------------------------------------
580 // Zoom controls
581 // -------------------------------------------------------------------------
582 RowLayout {
583 Layout.fillWidth: true
584 spacing: ScreenTools.defaultFontPixelWidth
585
586 QGCLabel {
587 Layout.fillWidth: true
588 text: qsTr("Click to place cursor. Shift+drag to move cursor. Drag to zoom X-axis. Double-click to reset zoom.")
589 }
590
591 QGCButton {
592 text: qsTr("Reset Zoom")
593 enabled: _base.zoomMinX !== _base.fullMinX || _base.zoomMaxX !== _base.fullMaxX
594 onClicked: _base.resetZoom()
595 }
596 }
597}