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 })
217 _base.graphsView.addSeries(series)
218 _seriesByField[fieldName] = series
219
220 // Compute full-dataset min/max once when the series is first created
221 const fr = logParser.fieldMinMax(fieldName)
222 _fieldFullRange[fieldName] = (fr && fr.min !== undefined && fr.min <= fr.max) ? { min: fr.min, max: fr.max } : null
223 }
224
225 if (!points || points.length === 0) {
226 _fieldYRange[fieldName] = { min: 0, max: 1 }
227 continue
228 }
229
230 let minY = Number.MAX_VALUE
231 let maxY = -Number.MAX_VALUE
232 for (let j = 0; j < points.length; j++) {
233 series.append(points[j].x, points[j].y)
234 if (points[j].y < minY) minY = points[j].y
235 if (points[j].y > maxY) maxY = points[j].y
236 }
237 _fieldYRange[fieldName] = { min: minY, max: maxY }
238 }
239
240 for (let i = 0; i < newSelection.length; i++) {
241 const fn = String(newSelection[i])
242 const s = _seriesByField[fn]
243 if (s) s.color = fieldColor(fn)
244 }
245
246 const allTracked = Object.keys(_seriesByField)
247 let globalMinY = 0
248 let globalMaxY = 1
249 if (allTracked.length > 0) {
250 globalMinY = Number.MAX_VALUE
251 globalMaxY = -Number.MAX_VALUE
252 for (let i = 0; i < allTracked.length; i++) {
253 const r = _fieldYRange[allTracked[i]]
254 if (r) {
255 if (r.min < globalMinY) globalMinY = r.min
256 if (r.max > globalMaxY) globalMaxY = r.max
257 }
258 }
259 if (globalMinY === globalMaxY) globalMaxY = globalMinY + 1
260 }
261 _base.yAxis.min = globalMinY
262 _base.yAxis.max = globalMaxY
263
264 const existingTypes = Object.keys(_eventSeriesByType)
265 for (let i = 0; i < existingTypes.length; i++) {
266 _base.graphsView.removeSeries(_eventSeriesByType[existingTypes[i]])
267 }
268 _eventSeriesByType = {}
269 const eventList = logParser.events
270 for (let e = 0; e < eventList.length; e++) {
271 const ev = eventList[e]
272 if (ev.time < _base.xAxis.min || ev.time > _base.xAxis.max) continue
273 if (!_eventSeriesByType[ev.type]) {
274 const eventSeries = _scatterSeriesComponent.createObject(_base.graphsView, {
275 color: eventColor(ev.type)
276 })
277 _base.graphsView.addSeries(eventSeries)
278 _eventSeriesByType[ev.type] = eventSeries
279 }
280 _eventSeriesByType[ev.type].append(ev.time, _base.yAxis.max)
281 }
282
283 if (_base.markerVisible) {
284 Qt.callLater(_queryCursorValues)
285 }
286 }
287
288 // -------------------------------------------------------------------------
289 // Internal connections
290 // -------------------------------------------------------------------------
291 Connections {
292 target: logViewerController
293 function onSelectedFieldsChanged() {
294 Qt.callLater(_syncSeriesWithSelection)
295 }
296 }
297
298 Connections {
299 target: _base
300 function onZoomRangeSet(minX, maxX) { Qt.callLater(_syncSeriesWithSelection) }
301 function onCursorPositionSet(t) { _queryCursorValues() }
302 function onCursorMoved(t) { cursorMoved(t) }
303 function onZoomApplied(minX, maxX) { zoomApplied(minX, maxX) }
304 }
305
306 // -------------------------------------------------------------------------
307 // Timeline bars (offset to align with chart plot area)
308 // -------------------------------------------------------------------------
309 ColumnLayout {
310 id: _timelineContainer
311 Layout.preferredWidth: _base.graphsView.plotArea.x + _base.graphsView.plotArea.width
312 spacing: ScreenTools.defaultFontPixelHeight * 0.1
313
314 property real _barHeight: ScreenTools.defaultFontPixelHeight * 0.6
315
316 function _segmentX(start) {
317 const w = _base.graphsView.plotArea.width
318 if (_base.xAxis.max <= _base.xAxis.min) return 0
319 return Math.max(0, Math.min(w, ((Math.max(start, _base.xAxis.min) - _base.xAxis.min) / (_base.xAxis.max - _base.xAxis.min)) * w))
320 }
321
322 function _segmentWidth(start, end) {
323 const w = _base.graphsView.plotArea.width
324 if (_base.xAxis.max <= _base.xAxis.min) return 0
325 return ((Math.min(end, _base.xAxis.max) - Math.max(start, _base.xAxis.min)) / (_base.xAxis.max - _base.xAxis.min)) * w
326 }
327
328 function _eventX(time, itemWidth) {
329 const w = _base.graphsView.plotArea.width
330 if (_base.xAxis.max <= _base.xAxis.min) return 0
331 return Math.max(0, Math.min(w - itemWidth, ((time - _base.xAxis.min) / (_base.xAxis.max - _base.xAxis.min)) * w))
332 }
333
334 // Bar 1: Flight modes
335 RowLayout {
336 Layout.fillWidth: true
337 visible: logParser.modeSegments.length > 0
338
339 QGCLabel {
340 text: qsTr("Modes")
341 font.bold: true
342 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
343 Layout.maximumWidth: Layout.preferredWidth
344 }
345
346 Item {
347 Layout.fillWidth: true
348 Layout.preferredHeight: _timelineContainer._barHeight
349
350 Repeater {
351 model: logParser.modeSegments
352
353 Rectangle {
354 visible: _base.xAxis.max > _base.xAxis.min && modelData.end >= _base.xAxis.min && modelData.start <= _base.xAxis.max
355 height: parent.height
356 color: modeColor(modelData.mode)
357 x: _timelineContainer._segmentX(modelData.start)
358 width: Math.max(1, _timelineContainer._segmentWidth(modelData.start, modelData.end))
359 }
360 }
361 }
362 }
363
364 // Bar 2: Dropouts
365 RowLayout {
366 Layout.fillWidth: true
367 visible: logParser.dropouts.length > 0
368
369 QGCLabel {
370 text: qsTr("Dropouts")
371 font.bold: true
372 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
373 Layout.maximumWidth: Layout.preferredWidth
374 }
375
376 Item {
377 Layout.fillWidth: true
378 Layout.preferredHeight: _timelineContainer._barHeight
379
380 Repeater {
381 model: logParser.dropouts
382
383 Rectangle {
384 visible: _base.xAxis.max > _base.xAxis.min && modelData.end >= _base.xAxis.min && modelData.start <= _base.xAxis.max
385 height: parent.height
386 color: Qt.alpha(eventColor("error"), 0.533)
387 x: _timelineContainer._segmentX(modelData.start)
388 width: Math.max(2, _timelineContainer._segmentWidth(modelData.start, modelData.end))
389 }
390 }
391 }
392 }
393
394 // Bar 3: Events
395 RowLayout {
396 Layout.fillWidth: true
397 visible: logParser.events.length > 0
398
399 QGCLabel {
400 text: qsTr("Events")
401 font.bold: true
402 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
403 Layout.maximumWidth: Layout.preferredWidth
404 }
405
406 Item {
407 Layout.fillWidth: true
408 Layout.preferredHeight: _timelineContainer._barHeight
409
410 Repeater {
411 model: logParser.events
412
413 Rectangle {
414 width: 2
415 height: parent.height
416 visible: _base.xAxis.max > _base.xAxis.min
417 color: eventColor(modelData.type)
418 x: _timelineContainer._eventX(modelData.time, width)
419 }
420 }
421 }
422 }
423 }
424
425 // -------------------------------------------------------------------------
426 // Chart area
427 // -------------------------------------------------------------------------
428 LogViewerBaseChart {
429 id: _base
430 Layout.fillWidth: true
431 Layout.fillHeight: true
432 logParser: control.logParser
433 xAxisShowLocalTime: control.xAxisShowLocalTime
434 yAxisTitle: qsTr("Value")
435
436 // ---- Chart-specific popup rows ----
437 RowLayout {
438 visible: _markerModeName.length > 0
439 spacing: ScreenTools.defaultFontPixelWidth * 0.2
440
441 Rectangle {
442 Layout.preferredWidth: _base.colorBlockWidth
443 Layout.preferredHeight: _base.colorBlockWidth
444 color: modeColor(_markerModeName)
445 }
446
447 QGCLabel { text: qsTr("Mode:") }
448 QGCLabel { text: _markerModeName; font.bold: true }
449 }
450
451 Repeater {
452 model: _markerRows
453
454 ColumnLayout {
455 spacing: ScreenTools.defaultFontPixelHeight * 0.15
456
457 RowLayout {
458 spacing: ScreenTools.defaultFontPixelWidth * 0.4
459
460 Rectangle {
461 Layout.preferredWidth: _base.colorBlockWidth
462 Layout.preferredHeight: _base.colorBlockWidth
463 color: modelData.color
464 }
465
466 QGCLabel {
467 width: _base.popupWidth - (ScreenTools.defaultFontPixelWidth * 4)
468 elide: Text.ElideMiddle
469 text: modelData.name
470 font.bold: true
471 }
472 }
473
474 RowLayout {
475 Layout.leftMargin: _base.colorBlockWidth + ScreenTools.defaultFontPixelWidth * 0.4
476 spacing: ScreenTools.defaultFontPixelWidth * 0.3
477
478 QGCLabel { text: qsTr("Current") }
479 QGCLabel { text: Number(modelData.value).toFixed(3); font.bold: true }
480
481 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
482
483 QGCLabel { text: qsTr("Min") }
484 QGCLabel { text: isNaN(modelData.min) ? "—" : Number(modelData.min).toFixed(3); font.bold: true }
485
486 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
487
488 QGCLabel { text: qsTr("Max") }
489 QGCLabel { text: isNaN(modelData.max) ? "—" : Number(modelData.max).toFixed(3); font.bold: true }
490 }
491 }
492 }
493
494 Repeater {
495 model: _markerEventRows
496
497 RowLayout {
498 spacing: ScreenTools.defaultFontPixelWidth * 0.2
499
500 Rectangle {
501 Layout.preferredWidth: _base.colorBlockWidth
502 Layout.preferredHeight: _base.colorBlockWidth
503 color: modelData.color
504 }
505
506 QGCLabel {
507 Layout.maximumWidth: ScreenTools.defaultFontPixelWidth * 20
508 wrapMode: Text.WordWrap
509 maximumLineCount: 2
510 text: modelData.text
511 }
512 }
513 }
514 }
515
516 // -------------------------------------------------------------------------
517 // Legends
518 // -------------------------------------------------------------------------
519 Row {
520 Layout.fillWidth: true
521 Layout.preferredHeight: _legendRowHeight
522 visible: modeLegendEntries().length > 0
523 spacing: ScreenTools.defaultFontPixelWidth
524
525 QGCLabel { text: qsTr("Modes:"); font.bold: true }
526
527 Repeater {
528 model: modeLegendEntries()
529
530 RowLayout {
531 spacing: _legendItemSpacing
532
533 Rectangle {
534 Layout.preferredWidth: _legendColorBlockSize
535 Layout.preferredHeight: _legendColorBlockSize
536 color: modeColor(modelData)
537 }
538
539 QGCLabel { text: modelData }
540 }
541 }
542 }
543
544 Row {
545 Layout.fillWidth: true
546 Layout.preferredHeight: _legendRowHeight
547 visible: logParser.events.length > 0
548 spacing: ScreenTools.defaultFontPixelWidth
549
550 QGCLabel { text: qsTr("Events:"); font.bold: true }
551
552 Repeater {
553 model: ["mode", "event", "error", "warning"]
554
555 RowLayout {
556 spacing: _legendItemSpacing
557 visible: {
558 for (let i = 0; i < logParser.events.length; i++) {
559 if (logParser.events[i].type === modelData) return true
560 }
561 return false
562 }
563
564 Rectangle {
565 Layout.preferredWidth: _legendColorBlockSize
566 Layout.preferredHeight: _legendColorBlockSize
567 color: eventColor(modelData)
568 }
569
570 QGCLabel { text: eventTypeLabel(modelData) }
571 }
572 }
573 }
574
575 // -------------------------------------------------------------------------
576 // Zoom controls
577 // -------------------------------------------------------------------------
578 RowLayout {
579 Layout.fillWidth: true
580 spacing: ScreenTools.defaultFontPixelWidth
581
582 QGCLabel {
583 Layout.fillWidth: true
584 text: qsTr("Click to place cursor. Shift+drag to move cursor. Drag to zoom X-axis. Double-click to reset zoom.")
585 }
586
587 QGCButton {
588 text: qsTr("Reset Zoom")
589 enabled: _base.zoomMinX !== _base.fullMinX || _base.zoomMaxX !== _base.fullMaxX
590 onClicked: _base.resetZoom()
591 }
592 }
593}