QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
LogViewerPage.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
9AnalyzePage {
10 id: logViewerPage
11 pageComponent: pageComponent
12 pageDescription: qsTr("Open and inspect DataFlash (.bin), PX4 ULog (.ulg), and telemetry (.tlog) logs in a unified workflow.")
13
14 Component {
15 id: pageComponent
16
17 ColumnLayout {
18 width: availableWidth
19 height: availableHeight
20 spacing: ScreenTools.defaultFontPixelHeight
21
22 property bool binLoading: false
23 property string pendingBinFile: ""
24 property bool cursorVisible: false
25 property real cursorPixelX: 0
26 property real cursorXValue: 0
27 property real cursorPopupY: 0
28 property var cursorRows: []
29 property var cursorEventRows: []
30 property string cursorModeName: ""
31 property string fieldSearchText: ""
32 property string parameterSearchText: ""
33 property var filteredFieldRows: []
34 property var filteredParameters: []
35 property real fullMinX: 0
36 property real fullMaxX: 1
37 property real zoomMinX: 0
38 property real zoomMaxX: 1
39
40 // Maps fieldName → LineSeries and fieldName → {min, max} for incremental add/remove.
41 property var _seriesByField: ({})
42 property var _fieldYRange: ({})
43 // Maps eventType → ScatterSeries; rebuilt whenever field selection changes,
44 // because Y pin position depends on binYAxis.max which is recomputed at that time.
45 property var _eventSeriesByType: ({})
46
47 // Cap decimation to keep QtGraphs rendering below ~16ms per frame.
48 // Empirically, >~6000 points per series causes visible frame drops on mid-range hardware.
49 property int maxChartPointsPerField: 6000
50
51 readonly property bool isFirmwareLog: logViewerController.sourceType === LogViewerController.Bin
52 || logViewerController.sourceType === LogViewerController.ULog
53
54 // Visually distinct categorical palette for selected-field series.
55 // Position-based picks avoid hash collisions producing similar colors.
56 readonly property var _fieldChartColors: [
57 "#1E88E5", // blue
58 "#E53935", // red
59 "#43A047", // green
60 "#FB8C00", // orange
61 "#8E24AA", // purple
62 "#00ACC1", // cyan
63 "#FDD835", // yellow
64 "#D81B60", // pink
65 "#6D4C41", // brown
66 "#546E7A", // blue grey
67 "#3949AB", // indigo
68 "#00897B", // teal
69 ]
70
71 Component {
72 id: lineSeriesComponent
73
74 LineSeries { }
75 }
76
77 Component {
78 id: scatterSeriesComponent
79
80 ScatterSeries { }
81 }
82
83 function eventColor(eventType) {
84 return logViewerController.eventColor(eventType)
85 }
86
87 function eventTypeLabel(eventType) {
88 if (eventType === "mode") {
89 return qsTr("Mode")
90 }
91 if (eventType === "event") {
92 return qsTr("Event")
93 }
94 if (eventType === "error") {
95 return qsTr("Error")
96 }
97 if (eventType === "warning") {
98 return qsTr("Warning")
99 }
100 return eventType
101 }
102
103 function modeColor(modeName) {
104 return logViewerController.modeColor(String(modeName))
105 }
106
107 function modeLegendEntries() {
108 return logViewerController.modeLegendEntries(logParser.modeSegments)
109 }
110
111 function rebuildGroupedFields() {
112 logViewerController.setPlottableFields(logParser.plottableFields)
113 applyFieldFilter()
114 }
115
116 function applyFieldFilter() {
117 const query = String(fieldSearchText).trim().toLowerCase()
118 if (query.length === 0) {
119 filteredFieldRows = logViewerController.fieldRows
120 return
121 }
122
123 const groupedMap = {}
124 const fields = logParser.plottableFields
125 for (let i = 0; i < fields.length; i++) {
126 const fullName = String(fields[i])
127 const splitIndex = fullName.indexOf(".")
128 const groupName = splitIndex > 0 ? fullName.substring(0, splitIndex) : qsTr("Other")
129 const shortName = splitIndex > 0 ? fullName.substring(splitIndex + 1) : fullName
130 const haystack = (fullName + " " + groupName + " " + shortName).toLowerCase()
131 if (haystack.indexOf(query) === -1) {
132 continue
133 }
134
135 if (!groupedMap[groupName]) {
136 groupedMap[groupName] = []
137 }
138 groupedMap[groupName].push({ fullName: fullName, shortName: shortName })
139 }
140
141 const groups = Object.keys(groupedMap).sort()
142 const rows = []
143 for (let g = 0; g < groups.length; g++) {
144 const groupName = groups[g]
145 rows.push({ rowType: "group", group: groupName })
146 groupedMap[groupName].sort((a, b) => String(a.shortName).localeCompare(String(b.shortName)))
147 for (let s = 0; s < groupedMap[groupName].length; s++) {
148 rows.push({
149 rowType: "field",
150 group: groupName,
151 fullName: groupedMap[groupName][s].fullName,
152 shortName: groupedMap[groupName][s].shortName
153 })
154 }
155 }
156 filteredFieldRows = rows
157 }
158
159 function applyParameterFilter() {
160 const query = String(parameterSearchText).trim().toLowerCase()
161 if (query.length === 0) {
162 filteredParameters = logParser.parameters
163 return
164 }
165
166 const output = []
167 for (let i = 0; i < logParser.parameters.length; i++) {
168 const item = logParser.parameters[i]
169 const name = String(item.name)
170 const value = String(item.value)
171 if ((name + " " + value).toLowerCase().indexOf(query) !== -1) {
172 output.push(item)
173 }
174 }
175 filteredParameters = output
176 }
177
178 function isGroupExpanded(groupName) {
179 return logViewerController.isGroupExpanded(groupName)
180 }
181
182 function toggleGroupExpanded(groupName) {
183 if (String(fieldSearchText).trim().length > 0) {
184 return
185 }
186 logViewerController.toggleGroupExpanded(groupName)
187 applyFieldFilter()
188 }
189
190 function isFieldSelected(fieldName) {
191 return logViewerController.isFieldSelected(fieldName)
192 }
193
194 function fieldColor(fieldName) {
195 const idx = logViewerController.selectedFields.indexOf(fieldName)
196 if (idx >= 0) {
197 return _fieldChartColors[idx % _fieldChartColors.length]
198 }
199 return logViewerController.fieldColor(fieldName)
200 }
201
202 function applyZoomRange(minX, maxX) {
203 if (maxX <= minX) {
204 return
205 }
206 zoomMinX = minX
207 zoomMaxX = maxX
208 binXAxis.min = zoomMinX
209 binXAxis.max = zoomMaxX
210 }
211
212 function resetZoom() {
213 applyZoomRange(fullMinX, fullMaxX)
214 }
215
216 function _pixelToAxisX(pixelX) {
217 const plotX = binChart.plotArea.x
218 const plotW = binChart.plotArea.width
219 if (plotW <= 0 || binXAxis.max <= binXAxis.min) {
220 return binXAxis.min
221 }
222
223 const clampedPixel = Math.max(plotX, Math.min(plotX + plotW, pixelX))
224 const ratio = (clampedPixel - plotX) / plotW
225 return binXAxis.min + (ratio * (binXAxis.max - binXAxis.min))
226 }
227
228 function updateCursorInfo(pixelX, pixelY, width, height) {
229 const selectedFields = logViewerController.selectedFields
230 if (selectedFields.length === 0 || width <= 0 || height <= 0) {
231 cursorVisible = false
232 return
233 }
234
235 cursorVisible = true
236 cursorPixelX = Math.max(binChart.plotArea.x, Math.min(binChart.plotArea.x + binChart.plotArea.width, pixelX))
237 cursorXValue = _pixelToAxisX(cursorPixelX)
238 cursorPopupY = Math.max(0, Math.min(height - (ScreenTools.defaultFontPixelHeight * 4), pixelY))
239 cursorModeName = logParser.modeAt(cursorXValue)
240
241 const rows = []
242 for (let i = 0; i < selectedFields.length; i++) {
243 const field = selectedFields[i]
244 const value = logParser.fieldValueAt(field, cursorXValue)
245 if (isNaN(value)) {
246 continue
247 }
248 rows.push({
249 name: field,
250 color: fieldColor(field),
251 value: value
252 })
253 }
254 cursorRows = rows
255
256 const threshold = Math.max(0.05, (binXAxis.max - binXAxis.min) / 200.0)
257 const nearbyEvents = logParser.eventsNear(cursorXValue, threshold)
258 const events = []
259 for (let i = 0; i < nearbyEvents.length; i++) {
260 events.push({
261 color: eventColor(nearbyEvents[i].type),
262 text: nearbyEvents[i].description
263 })
264 }
265 cursorEventRows = events
266 }
267
268 function clearLoadedLogState(clearControllerState) {
269 replayController.isPlaying = false
270 replayController.link = null
271 logParser.clear()
272 logViewerController.setPlottableFields([])
273 logViewerController.clearSelection()
274 cursorEventRows = []
275 filteredFieldRows = []
276 filteredParameters = []
277 refreshBinChart()
278 if (clearControllerState) {
279 logViewerController.clear()
280 }
281 }
282
283 function loadBinFile(file) {
284 if (logViewerController.hasLoadedLog) {
285 // Match explicit "Clear" behavior before loading replacement .bin file.
286 clearLoadedLogState(true)
287 }
288 pendingBinFile = file
289 binLoading = true
290 parseStartTimer.start()
291 }
292
293 function _executePendingBinParse() {
294 if (!pendingBinFile || pendingBinFile.length === 0) {
295 binLoading = false
296 return
297 }
298
299 const file = pendingBinFile
300 logParser.parseFileAsync(file)
301 }
302
303 // Full reset: remove all series and reinitialise axes from the log time range.
304 // Called on log load, on full clear (clearLoadedLogState), and when all selected fields
305 // are cleared via the "Clear Selected" button. For incremental selection changes use
306 // _syncSeriesWithSelection instead.
307 function refreshBinChart() {
308 while (binChart.seriesList.length > 0) {
309 binChart.removeSeries(binChart.seriesList[0])
310 }
311 _seriesByField = {}
312 _fieldYRange = {}
313 _eventSeriesByType = {}
314
315 if (logParser.minTimestamp >= 0.0 && logParser.maxTimestamp > logParser.minTimestamp) {
316 fullMinX = logParser.minTimestamp
317 fullMaxX = logParser.maxTimestamp
318 } else {
319 fullMinX = 0
320 fullMaxX = 1
321 }
322 zoomMinX = fullMinX
323 zoomMaxX = fullMaxX
324 binXAxis.min = zoomMinX
325 binXAxis.max = zoomMaxX
326 binYAxis.min = 0
327 binYAxis.max = 1
328 }
329
330 // Incremental update: diff the current series map against the new selection,
331 // remove series for deselected fields, add series for newly selected fields,
332 // then recompute the Y axis and rebuild the event scatter series.
333 function _syncSeriesWithSelection() {
334 const newSelection = logViewerController.selectedFields
335
336 // Build lookup of desired fields
337 const desired = {}
338 for (let i = 0; i < newSelection.length; i++) {
339 desired[String(newSelection[i])] = true
340 }
341
342 // Remove series for fields that are no longer selected
343 const tracked = Object.keys(_seriesByField)
344 for (let i = 0; i < tracked.length; i++) {
345 if (!desired[tracked[i]]) {
346 binChart.removeSeries(_seriesByField[tracked[i]])
347 delete _seriesByField[tracked[i]]
348 delete _fieldYRange[tracked[i]]
349 }
350 }
351
352 // Add a series for each newly selected field
353 for (let i = 0; i < newSelection.length; i++) {
354 const fieldName = String(newSelection[i])
355 if (_seriesByField[fieldName]) {
356 continue
357 }
358
359 const points = logParser.fieldSamples(fieldName)
360 if (!points || points.length === 0) {
361 continue
362 }
363
364 const series = lineSeriesComponent.createObject(binChart, {
365 color: fieldColor(fieldName),
366 width: 2,
367 axisX: binXAxis,
368 axisY: binYAxis
369 })
370 binChart.addSeries(series)
371
372 let minY = Number.MAX_VALUE
373 let maxY = -Number.MAX_VALUE
374 const sampleStep = Math.max(1, Math.ceil(points.length / maxChartPointsPerField))
375 let appendedLastX = -Number.MAX_VALUE
376 for (let j = 0; j < points.length; j += sampleStep) {
377 series.append(points[j].x, points[j].y)
378 appendedLastX = points[j].x
379 if (points[j].y < minY) minY = points[j].y
380 if (points[j].y > maxY) maxY = points[j].y
381 }
382 if (sampleStep > 1) {
383 const last = points[points.length - 1]
384 if (last && last.x !== appendedLastX) {
385 series.append(last.x, last.y)
386 }
387 }
388
389 _seriesByField[fieldName] = series
390 _fieldYRange[fieldName] = { min: minY, max: maxY }
391 }
392
393 // Update colors for all tracked series (selection indices may have shifted)
394 for (let i = 0; i < newSelection.length; i++) {
395 const fn = String(newSelection[i])
396 const s = _seriesByField[fn]
397 if (s) s.color = fieldColor(fn)
398 }
399
400 // Recompute Y axis from cached per-field ranges
401 const allTracked = Object.keys(_seriesByField)
402 let globalMinY = 0
403 let globalMaxY = 1
404 if (allTracked.length > 0) {
405 globalMinY = Number.MAX_VALUE
406 globalMaxY = -Number.MAX_VALUE
407 for (let i = 0; i < allTracked.length; i++) {
408 const r = _fieldYRange[allTracked[i]]
409 if (r) {
410 if (r.min < globalMinY) globalMinY = r.min
411 if (r.max > globalMaxY) globalMaxY = r.max
412 }
413 }
414 if (globalMinY === globalMaxY) globalMaxY = globalMinY + 1
415 }
416 binYAxis.min = globalMinY
417 binYAxis.max = globalMaxY
418
419 // Rebuild event scatter series (Y position is pinned to current binYAxis.max)
420 const existingTypes = Object.keys(_eventSeriesByType)
421 for (let i = 0; i < existingTypes.length; i++) {
422 binChart.removeSeries(_eventSeriesByType[existingTypes[i]])
423 }
424 _eventSeriesByType = {}
425 const eventList = logParser.events
426 for (let e = 0; e < eventList.length; e++) {
427 const ev = eventList[e]
428 if (ev.time < binXAxis.min || ev.time > binXAxis.max) {
429 continue
430 }
431 if (!_eventSeriesByType[ev.type]) {
432 const eventSeries = scatterSeriesComponent.createObject(binChart, {
433 color: eventColor(ev.type),
434 axisX: binXAxis,
435 axisY: binYAxis
436 })
437 binChart.addSeries(eventSeries)
438 _eventSeriesByType[ev.type] = eventSeries
439 }
440 _eventSeriesByType[ev.type].append(ev.time, binYAxis.max)
441 }
442 }
443
444 LogViewerController {
445 id: logViewerController
446 }
447
448 LogFileParser {
449 id: logParser
450 }
451
452 Connections {
453 target: logViewerController
454 function onFieldRowsChanged() {
455 applyFieldFilter()
456 }
457 function onSelectedFieldsChanged() {
458 Qt.callLater(_syncSeriesWithSelection)
459 }
460 }
461
462 Connections {
463 target: logParser
464 ignoreUnknownSignals: true
465
466 function onParseFileFinished(filePath, ok, errorMessage) {
467 if (filePath !== pendingBinFile) {
468 return
469 }
470
471 if (!ok) {
472 QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), errorMessage)
473 binLoading = false
474 pendingBinFile = ""
475 return
476 }
477
478 rebuildGroupedFields()
479 applyParameterFilter()
480 logViewerController.clearSelection()
481 fullMinX = 0
482 fullMaxX = 1
483 zoomMinX = 0
484 zoomMaxX = 1
485 refreshBinChart()
486
487 const lowerPath = filePath.toLowerCase()
488 if (lowerPath.endsWith(".ulg")) {
489 logViewerController.openULogFile(filePath)
490 } else {
491 logViewerController.openBinLog(filePath)
492 }
493 binLoading = false
494 pendingBinFile = ""
495 }
496 }
497
498 LogReplayLinkController {
499 id: replayController
500 }
501
502 RowLayout {
503 Layout.fillWidth: true
504 spacing: ScreenTools.defaultFontPixelWidth
505
506 QGCButton {
507 text: qsTr("Open .bin")
508 onClicked: {
509 openDialog.nameFilters = ["DataFlash Logs (*.bin *.BIN *.log *.LOG)"]
510 openDialog.openForLoad()
511 }
512 }
513
514 QGCButton {
515 text: qsTr("Open .ulg")
516 onClicked: {
517 openDialog.nameFilters = ["PX4 ULog Files (*.ulg *.ULG)"]
518 openDialog.openForLoad()
519 }
520 }
521
522 QGCButton {
523 text: qsTr("Open .tlog")
524 onClicked: {
525 const activeVehicle = QGroundControl.multiVehicleManager.activeVehicle
526 if (activeVehicle && !activeVehicle.isOfflineEditingVehicle) {
527 QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), qsTr("Close active vehicle connections before starting telemetry replay."))
528 return
529 }
530 openDialog.nameFilters = ["Telemetry Logs (*.tlog *.TLOG)"]
531 openDialog.openForLoad()
532 }
533 }
534
535 QGCButton {
536 text: qsTr("Clear")
537 enabled: logViewerController.hasLoadedLog
538 onClicked: {
539 clearLoadedLogState(true)
540 }
541 }
542
543 QGCLabel {
544 Layout.fillWidth: true
545 elide: Text.ElideMiddle
546 text: logViewerController.hasLoadedLog ? logViewerController.currentLogPath : qsTr("No log selected")
547 }
548 }
549
550 RowLayout {
551 Layout.fillWidth: true
552 visible: logViewerController.sourceType === LogViewerController.TLog && replayController.link
553 spacing: ScreenTools.defaultFontPixelWidth
554
555 QGCButton {
556 text: replayController.isPlaying ? qsTr("Pause") : qsTr("Play")
557 onClicked: replayController.isPlaying = !replayController.isPlaying
558 }
559
560 QGCComboBox {
561 textRole: "text"
562 currentIndex: 3
563
564 model: ListModel {
565 ListElement { text: "0.1x"; value: 0.1 }
566 ListElement { text: "0.25x"; value: 0.25 }
567 ListElement { text: "0.5x"; value: 0.5 }
568 ListElement { text: "1x"; value: 1.0 }
569 ListElement { text: "2x"; value: 2.0 }
570 ListElement { text: "5x"; value: 5.0 }
571 ListElement { text: "10x"; value: 10.0 }
572 }
573
574 onActivated: (index) => replayController.playbackSpeed = model.get(index).value
575 }
576
577 QGCLabel { text: replayController.playheadTime }
578
579 Slider {
580 id: replaySlider
581 Layout.fillWidth: true
582 from: 0
583 to: 100
584
585 property bool _internalUpdate: false
586
587 Connections {
588 target: replayController
589 function onPercentCompleteChanged(percentComplete) {
590 replaySlider._internalUpdate = true
591 replaySlider.value = percentComplete
592 replaySlider._internalUpdate = false
593 }
594 }
595
596 onValueChanged: {
597 if (!_internalUpdate) {
598 replayController.percentComplete = value
599 }
600 }
601 }
602
603 QGCLabel { text: replayController.totalTime }
604 }
605
606 RowLayout {
607 Layout.fillWidth: true
608 Layout.fillHeight: true
609 spacing: ScreenTools.defaultFontPixelWidth
610
611 Rectangle {
612 Layout.preferredWidth: availableWidth * 0.25
613 Layout.fillHeight: true
614 color: qgcPal.windowShade
615 radius: ScreenTools.defaultFontPixelWidth * 0.5
616
617 ColumnLayout {
618 anchors.fill: parent
619 anchors.margins: ScreenTools.defaultFontPixelWidth
620 spacing: ScreenTools.defaultFontPixelHeight * 0.5
621
622 QGCLabel {
623 text: qsTr("Messages / Parameters")
624 font.bold: true
625 }
626
627 QGCLabel {
628 Layout.fillWidth: true
629 wrapMode: Text.WordWrap
630 maximumLineCount: 3
631 text: (isFirmwareLog)
632 ? qsTr("DataFlash message and parameter browser will appear here.")
633 : qsTr("For telemetry replay, MAVLink message and field selection is available through the Inspector integration.")
634 }
635
636 QGCLabel {
637 visible: isFirmwareLog
638 text: qsTr("Fields: %1 Parameters: %2 Events: %3")
639 .arg(logParser.plottableFields.length)
640 .arg(logParser.parameters.length)
641 .arg(logParser.events.length)
642 }
643
644 QGCLabel {
645 visible: isFirmwareLog
646 text: qsTr("Detected vehicle type: %1")
647 .arg(logParser.detectedVehicleType.length > 0
648 ? logParser.detectedVehicleType
649 : qsTr("Unknown"))
650 }
651
652 RowLayout {
653 Layout.fillWidth: true
654 visible: isFirmwareLog
655 spacing: ScreenTools.defaultFontPixelWidth * 0.5
656
657 QGCLabel {
658 text: qsTr("Fields (click to plot)")
659 font.bold: true
660 }
661
662 QGCTextField {
663 id: fieldSearchField
664 Layout.fillWidth: true
665 textColor: qgcPal.textFieldText
666 placeholderTextColor: Qt.rgba(qgcPal.textFieldText.r, qgcPal.textFieldText.g, qgcPal.textFieldText.b, 0.7)
667 placeholderText: qsTr("Search fields")
668 onTextChanged: {
669 fieldSearchText = text
670 if (text.trim().length === 0) {
671 fieldSearchTimer.stop()
672 applyFieldFilter()
673 } else {
674 fieldSearchTimer.restart()
675 }
676 }
677 onAccepted: {
678 fieldSearchText = text
679 fieldSearchTimer.stop()
680 applyFieldFilter()
681 }
682 }
683
684 QGCButton {
685 text: qsTr("Clear Selected")
686 horizontalAlignment: Text.AlignHCenter
687 Layout.preferredHeight: fieldSearchField.implicitHeight
688 Layout.minimumHeight: fieldSearchField.implicitHeight
689 topPadding: 0
690 bottomPadding: 0
691 enabled: logViewerController.selectedFields.length > 0
692 onClicked: {
693 logViewerController.clearSelection()
694 applyFieldFilter()
695 refreshBinChart()
696 }
697 }
698 }
699
700 ScrollView {
701 id: fieldsScroll
702 Layout.fillWidth: true
703 Layout.preferredHeight: parent.height * 0.35
704 visible: isFirmwareLog
705 clip: true
706
707 ListView {
708 id: fieldsListView
709 anchors.fill: parent
710 model: filteredFieldRows
711 spacing: ScreenTools.defaultFontPixelHeight * 0.15
712 clip: true
713 ScrollBar.vertical: ScrollBar { }
714
715 delegate: Item {
716 width: fieldsListView.width
717 height: (modelData.rowType === "group")
718 ? (groupRect.implicitHeight + (ScreenTools.defaultFontPixelHeight * 0.1))
719 : (fieldRow.implicitHeight + (ScreenTools.defaultFontPixelHeight * 0.1))
720
721 Rectangle {
722 id: groupRect
723 visible: modelData.rowType === "group"
724 width: parent.width
725 implicitHeight: groupLabel.implicitHeight + (ScreenTools.defaultFontPixelHeight * 0.35)
726 color: qgcPal.windowShadeDark
727 radius: ScreenTools.defaultFontPixelWidth * 0.25
728
729 Row {
730 anchors.fill: parent
731 anchors.leftMargin: ScreenTools.defaultFontPixelWidth * 0.2
732 anchors.verticalCenter: parent.verticalCenter
733 spacing: ScreenTools.defaultFontPixelWidth * 0.3
734
735 QGCLabel { text: (String(fieldSearchText).trim().length > 0) ? "▼" : (isGroupExpanded(modelData.group) ? "▼" : "▶") }
736 QGCLabel { id: groupLabel; text: modelData.group; font.bold: true }
737 }
738
739 MouseArea {
740 anchors.fill: parent
741 onClicked: toggleGroupExpanded(modelData.group)
742 }
743 }
744
745 Row {
746 id: fieldRow
747 visible: modelData.rowType === "field"
748 width: parent.width
749 height: Math.max(fieldNameLabel.implicitHeight, fieldCheckBox.implicitHeight)
750 spacing: ScreenTools.defaultFontPixelWidth * 0.25
751
752 QGCCheckBox {
753 id: fieldCheckBox
754 anchors.verticalCenter: parent.verticalCenter
755 onClicked: logViewerController.setFieldSelected(modelData.fullName, checked)
756 checked: logViewerController.selectedFields.indexOf(modelData.fullName) !== -1
757 }
758
759 QGCLabel {
760 id: fieldNameLabel
761 anchors.verticalCenter: parent.verticalCenter
762 width: Math.max(0, parent.width - (ScreenTools.defaultFontPixelWidth * 3))
763 height: Math.max(implicitHeight, ScreenTools.defaultFontPixelHeight * 1.2)
764 wrapMode: Text.WordWrap
765 maximumLineCount: 2
766 verticalAlignment: Text.AlignVCenter
767 text: modelData.shortName ? String(modelData.shortName) : ""
768 color: isFieldSelected(modelData.fullName) ? fieldColor(modelData.fullName) : qgcPal.text
769 }
770 }
771 }
772 }
773 }
774
775 Rectangle {
776 Layout.fillWidth: true
777 Layout.preferredHeight: 1
778 color: qgcPal.windowShadeDark
779 visible: isFirmwareLog
780 }
781
782 QGCTabBar {
783 id: dataTabBar
784 Layout.fillWidth: true
785 visible: isFirmwareLog
786
787 QGCTabButton {
788 text: qsTr("Parameters")
789 checked: true
790 }
791
792 QGCTabButton {
793 text: qsTr("Messages")
794 checked: false
795 }
796 }
797
798 QGCTextField {
799 id: parameterSearchField
800 Layout.fillWidth: true
801 visible: isFirmwareLog && dataTabBar.currentIndex === 0
802 textColor: qgcPal.textFieldText
803 placeholderTextColor: Qt.rgba(qgcPal.textFieldText.r, qgcPal.textFieldText.g, qgcPal.textFieldText.b, 0.7)
804 placeholderText: qsTr("Search parameters")
805 onTextChanged: {
806 parameterSearchText = text
807 if (text.trim().length === 0) {
808 parameterSearchTimer.stop()
809 applyParameterFilter()
810 } else {
811 parameterSearchTimer.restart()
812 }
813 }
814 onAccepted: {
815 parameterSearchText = text
816 parameterSearchTimer.stop()
817 applyParameterFilter()
818 }
819 }
820
821 ScrollView {
822 Layout.fillWidth: true
823 Layout.fillHeight: true
824 Layout.minimumHeight: 0
825 visible: isFirmwareLog && dataTabBar.currentIndex === 0
826 clip: true
827
828 ListView {
829 id: parametersListView
830 anchors.fill: parent
831 model: filteredParameters
832 spacing: ScreenTools.defaultFontPixelHeight * 0.2
833 clip: true
834 ScrollBar.vertical: ScrollBar { }
835
836 delegate: QGCLabel {
837 width: ListView.view.width
838 wrapMode: Text.WordWrap
839 maximumLineCount: 2
840 text: modelData.name + " = " + modelData.value
841 }
842 }
843 }
844
845 ScrollView {
846 Layout.fillWidth: true
847 Layout.fillHeight: true
848 Layout.minimumHeight: 0
849 visible: isFirmwareLog && dataTabBar.currentIndex === 1
850 clip: true
851
852 ListView {
853 id: messagesListView
854 anchors.fill: parent
855 model: logParser.messages
856 spacing: ScreenTools.defaultFontPixelHeight * 0.2
857 clip: true
858 ScrollBar.vertical: ScrollBar { }
859
860 delegate: QGCLabel {
861 width: ListView.view.width
862 wrapMode: Text.WordWrap
863 maximumLineCount: 3
864 text: {
865 const t = Number(modelData.time)
866 const prefix = isNaN(t) || t < 0 ? "" : ("[" + t.toFixed(3) + "s] ")
867 return prefix + String(modelData.text)
868 }
869 }
870 }
871 }
872
873 }
874 }
875
876 Rectangle {
877 Layout.fillWidth: true
878 Layout.fillHeight: true
879 color: qgcPal.windowShadeDark
880 radius: ScreenTools.defaultFontPixelWidth * 0.5
881
882 ColumnLayout {
883 anchors.fill: parent
884 anchors.margins: ScreenTools.defaultFontPixelWidth
885 spacing: ScreenTools.defaultFontPixelHeight * 0.5
886
887 QGCLabel {
888 text: qsTr("Charts and Timeline")
889 font.bold: true
890 }
891
892 QGCLabel {
893 Layout.fillWidth: true
894 wrapMode: Text.WordWrap
895 maximumLineCount: 2
896 text: qsTr("Multi-series charts, event markers, and timeline controls are shown here.")
897 visible: logViewerController.sourceType !== LogViewerController.TLog
898 }
899
900 Item {
901 id: chartContainer
902 Layout.fillWidth: true
903 Layout.fillHeight: true
904 Layout.preferredHeight: parent.height * 0.72
905 visible: isFirmwareLog
906
907 GraphsView {
908 id: binChart
909 anchors.fill: parent
910 marginTop: 0
911 marginRight: 0
912 marginBottom: 0
913 marginLeft: 0
914
915 theme: GraphsTheme {
916 colorScheme: qgcPal.globalTheme === QGCPalette.Light ? GraphsTheme.ColorScheme.Light : GraphsTheme.ColorScheme.Dark
917 backgroundColor: qgcPal.windowShadeDark
918 backgroundVisible: true
919 plotAreaBackgroundColor: qgcPal.windowShadeDark
920 grid.mainColor: Qt.rgba(qgcPal.text.r, qgcPal.text.g, qgcPal.text.b, 0.25)
921 grid.subColor: Qt.rgba(qgcPal.text.r, qgcPal.text.g, qgcPal.text.b, 0.12)
922 grid.mainWidth: 1
923 labelBackgroundVisible: false
924 labelTextColor: qgcPal.text
925 axisXLabelFont.family: ScreenTools.fixedFontFamily
926 axisXLabelFont.pointSize: ScreenTools.smallFontPointSize
927 axisYLabelFont.family: ScreenTools.fixedFontFamily
928 axisYLabelFont.pointSize: ScreenTools.smallFontPointSize
929 }
930
931 axisX: ValueAxis {
932 id: binXAxis
933 titleText: qsTr("Time (s)")
934 min: 0
935 max: 1
936 }
937
938 axisY: ValueAxis {
939 id: binYAxis
940 titleText: qsTr("Value")
941 min: 0
942 max: 1
943 }
944 }
945
946 Rectangle {
947 id: zoomSelectionRect
948 visible: false
949 color: Qt.rgba(1, 1, 1, 0.2)
950 border.color: qgcPal.buttonHighlight
951 border.width: 1
952 z: 1000
953 }
954
955 MouseArea {
956 id: chartZoomArea
957 anchors.fill: parent
958 enabled: isFirmwareLog && (binXAxis.max > binXAxis.min)
959 hoverEnabled: true
960 acceptedButtons: Qt.LeftButton | Qt.RightButton
961 z: 1001
962
963 property real _dragStartX: 0
964
965 onPressed: (mouse) => {
966 if (mouse.button === Qt.RightButton) {
967 resetZoom()
968 return
969 }
970 _dragStartX = mouse.x
971 zoomSelectionRect.x = mouse.x
972 zoomSelectionRect.y = 0
973 zoomSelectionRect.width = 0
974 zoomSelectionRect.height = height
975 zoomSelectionRect.visible = true
976 updateCursorInfo(mouse.x, mouse.y, width, height)
977 }
978
979 onPositionChanged: (mouse) => {
980 if (pressed && zoomSelectionRect.visible) {
981 const left = Math.min(_dragStartX, mouse.x)
982 const right = Math.max(_dragStartX, mouse.x)
983 zoomSelectionRect.x = left
984 zoomSelectionRect.width = Math.max(0, right - left)
985 return
986 }
987
988 if (!pressed) {
989 updateCursorInfo(mouse.x, mouse.y, width, height)
990 return
991 }
992 }
993
994 onReleased: (mouse) => {
995 if (!zoomSelectionRect.visible) {
996 return
997 }
998
999 const dragWidth = zoomSelectionRect.width
1000 zoomSelectionRect.visible = false
1001 // Treat drags narrower than half a character width as accidental clicks, not zoom gestures.
1002 if (dragWidth < ScreenTools.defaultFontPixelWidth * 0.5) {
1003 return
1004 }
1005
1006 const leftX = _pixelToAxisX(zoomSelectionRect.x)
1007 const rightX = _pixelToAxisX(zoomSelectionRect.x + zoomSelectionRect.width)
1008 applyZoomRange(Math.min(leftX, rightX), Math.max(leftX, rightX))
1009 updateCursorInfo(mouse.x, mouse.y, width, height)
1010 }
1011
1012 onExited: {
1013 cursorVisible = false
1014 }
1015 }
1016
1017 Rectangle {
1018 visible: cursorVisible && (isFirmwareLog)
1019 x: cursorPixelX
1020 y: binChart.plotArea.y
1021 width: 1
1022 height: binChart.plotArea.height
1023 color: qgcPal.buttonHighlight
1024 z: 1002
1025 }
1026
1027 Rectangle {
1028 id: cursorPopup
1029 visible: cursorVisible && cursorRows.length > 0 && (isFirmwareLog)
1030 x: Math.max(0, Math.min(chartContainer.width - width, cursorPixelX + ScreenTools.defaultFontPixelWidth))
1031 y: cursorPopupY
1032 width: ScreenTools.defaultFontPixelWidth * 30
1033 color: qgcPal.windowShade
1034 border.color: qgcPal.windowShadeDark
1035 radius: ScreenTools.defaultFontPixelWidth * 0.3
1036 z: 1003
1037 implicitHeight: cursorColumn.implicitHeight + (ScreenTools.defaultFontPixelHeight * 0.6)
1038
1039 Column {
1040 id: cursorColumn
1041 anchors.fill: parent
1042 anchors.margins: ScreenTools.defaultFontPixelHeight * 0.3
1043 spacing: ScreenTools.defaultFontPixelHeight * 0.2
1044
1045 QGCLabel {
1046 text: qsTr("t=%1 s").arg(cursorXValue.toFixed(3))
1047 font.bold: true
1048 }
1049
1050 QGCLabel {
1051 visible: cursorModeName.length > 0
1052 text: qsTr("Mode: %1").arg(cursorModeName)
1053 font.bold: true
1054 color: cursorModeName.length > 0 ? modeColor(cursorModeName) : qgcPal.text
1055 }
1056
1057 Repeater {
1058 model: cursorRows
1059
1060 Row {
1061 spacing: ScreenTools.defaultFontPixelWidth * 0.2
1062
1063 Rectangle {
1064 width: ScreenTools.defaultFontPixelWidth * 0.8
1065 height: ScreenTools.defaultFontPixelHeight * 0.6
1066 color: modelData.color
1067 }
1068
1069 QGCLabel {
1070 width: cursorPopup.width - (ScreenTools.defaultFontPixelWidth * 4)
1071 elide: Text.ElideMiddle
1072 text: modelData.name + ": " + Number(modelData.value).toFixed(3)
1073 }
1074 }
1075 }
1076
1077 Repeater {
1078 model: cursorEventRows
1079
1080 Row {
1081 spacing: ScreenTools.defaultFontPixelWidth * 0.2
1082
1083 Rectangle {
1084 width: ScreenTools.defaultFontPixelWidth * 0.8
1085 height: ScreenTools.defaultFontPixelHeight * 0.6
1086 color: modelData.color
1087 }
1088
1089 QGCLabel {
1090 width: cursorPopup.width - (ScreenTools.defaultFontPixelWidth * 4)
1091 wrapMode: Text.WordWrap
1092 maximumLineCount: 2
1093 text: modelData.text
1094 }
1095 }
1096 }
1097 }
1098 }
1099 }
1100
1101 Rectangle {
1102 Layout.fillWidth: true
1103 Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 0.6
1104 visible: isFirmwareLog
1105 color: qgcPal.windowShade
1106
1107 Repeater {
1108 model: logParser.modeSegments
1109
1110 Rectangle {
1111 visible: binXAxis.max > binXAxis.min && modelData.end >= binXAxis.min && modelData.start <= binXAxis.max
1112 y: 0
1113 height: parent.height
1114 color: modeColor(modelData.mode)
1115 opacity: 1.0
1116 x: Math.max(binChart.plotArea.x,
1117 Math.min(binChart.plotArea.x + binChart.plotArea.width,
1118 binChart.plotArea.x + ((Math.max(modelData.start, binXAxis.min) - binXAxis.min) / (binXAxis.max - binXAxis.min)) * binChart.plotArea.width))
1119 width: Math.max(1, ((Math.min(modelData.end, binXAxis.max) - Math.max(modelData.start, binXAxis.min)) / (binXAxis.max - binXAxis.min)) * binChart.plotArea.width)
1120 }
1121 }
1122
1123 Repeater {
1124 model: logParser.dropouts
1125
1126 Rectangle {
1127 visible: binXAxis.max > binXAxis.min && modelData.end >= binXAxis.min && modelData.start <= binXAxis.max
1128 y: 0
1129 height: parent.height
1130 color: Qt.alpha(eventColor("error"), 0.533)
1131 x: Math.max(binChart.plotArea.x,
1132 Math.min(binChart.plotArea.x + binChart.plotArea.width,
1133 binChart.plotArea.x + ((Math.max(modelData.start, binXAxis.min) - binXAxis.min) / (binXAxis.max - binXAxis.min)) * binChart.plotArea.width))
1134 width: Math.max(2, ((Math.min(modelData.end, binXAxis.max) - Math.max(modelData.start, binXAxis.min)) / (binXAxis.max - binXAxis.min)) * binChart.plotArea.width)
1135 }
1136 }
1137
1138 Repeater {
1139 model: logParser.events
1140
1141 Rectangle {
1142 width: 2
1143 height: parent.height
1144 visible: binXAxis.max > binXAxis.min
1145 color: eventColor(modelData.type)
1146 x: Math.max(binChart.plotArea.x,
1147 Math.min((binChart.plotArea.x + binChart.plotArea.width) - width,
1148 binChart.plotArea.x + ((modelData.time - binXAxis.min) / (binXAxis.max - binXAxis.min)) * binChart.plotArea.width))
1149 }
1150 }
1151 }
1152
1153 Row {
1154 Layout.fillWidth: true
1155 visible: isFirmwareLog && modeLegendEntries().length > 0
1156 Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 1.1
1157 spacing: ScreenTools.defaultFontPixelWidth
1158
1159 QGCLabel {
1160 text: qsTr("Modes:")
1161 font.bold: true
1162 }
1163
1164 Repeater {
1165 model: modeLegendEntries()
1166
1167 Row {
1168 spacing: ScreenTools.defaultFontPixelWidth * 0.2
1169
1170 Rectangle {
1171 width: ScreenTools.defaultFontPixelWidth
1172 height: ScreenTools.defaultFontPixelHeight * 0.6
1173 color: modeColor(modelData)
1174 }
1175
1176 QGCLabel {
1177 text: modelData
1178 }
1179 }
1180 }
1181 }
1182
1183 Row {
1184 Layout.fillWidth: true
1185 visible: isFirmwareLog && logViewerController.selectedFields.length > 0
1186 Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 1.1
1187 spacing: ScreenTools.defaultFontPixelWidth
1188
1189 QGCLabel {
1190 text: qsTr("Fields:")
1191 font.bold: true
1192 }
1193
1194 Repeater {
1195 model: logViewerController.selectedFields
1196
1197 Row {
1198 spacing: ScreenTools.defaultFontPixelWidth * 0.2
1199
1200 Rectangle {
1201 width: ScreenTools.defaultFontPixelWidth
1202 height: ScreenTools.defaultFontPixelHeight * 0.6
1203 color: fieldColor(modelData)
1204 }
1205
1206 QGCLabel {
1207 text: modelData
1208 }
1209 }
1210 }
1211 }
1212
1213 Row {
1214 Layout.fillWidth: true
1215 visible: isFirmwareLog && logParser.events.length > 0
1216 Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 1.1
1217 spacing: ScreenTools.defaultFontPixelWidth
1218
1219 QGCLabel {
1220 text: qsTr("Events:")
1221 font.bold: true
1222 }
1223
1224 Repeater {
1225 model: ["mode", "event", "error", "warning"]
1226
1227 Row {
1228 spacing: ScreenTools.defaultFontPixelWidth * 0.2
1229 visible: {
1230 for (let i = 0; i < logParser.events.length; i++) {
1231 if (logParser.events[i].type === modelData) {
1232 return true
1233 }
1234 }
1235 return false
1236 }
1237
1238 Rectangle {
1239 width: ScreenTools.defaultFontPixelWidth
1240 height: ScreenTools.defaultFontPixelHeight * 0.6
1241 color: eventColor(modelData)
1242 }
1243
1244 QGCLabel {
1245 text: eventTypeLabel(modelData)
1246 }
1247 }
1248 }
1249 }
1250
1251 RowLayout {
1252 Layout.fillWidth: true
1253 visible: isFirmwareLog
1254 spacing: ScreenTools.defaultFontPixelWidth
1255
1256 QGCLabel {
1257 Layout.fillWidth: true
1258 text: qsTr("Drag on chart to zoom X-axis. Right click chart to reset zoom.")
1259 }
1260
1261 QGCButton {
1262 text: qsTr("Reset Zoom")
1263 enabled: zoomMinX !== fullMinX || zoomMaxX !== fullMaxX
1264 onClicked: resetZoom()
1265 }
1266 }
1267
1268 Loader {
1269 Layout.fillWidth: true
1270 Layout.fillHeight: true
1271 active: logViewerController.sourceType === LogViewerController.TLog
1272 source: "qrc:/qml/QGroundControl/AnalyzeView/MAVLinkInspector/MAVLinkInspectorPage.qml"
1273 }
1274 }
1275 }
1276 }
1277
1278 QGCLabel {
1279 Layout.fillWidth: true
1280 text: logViewerController.statusText
1281 visible: text.length > 0
1282 }
1283
1284 QGCFileDialog {
1285 id: openDialog
1286 title: qsTr("Select log file")
1287 folder: QGroundControl.settingsManager.appSettings.logSavePath
1288 selectFolder: false
1289
1290 onAcceptedForLoad: (file) => {
1291 const fileLower = file.toLowerCase()
1292 if (fileLower.endsWith(".tlog")) {
1293 if (logViewerController.hasLoadedLog) {
1294 clearLoadedLogState(true)
1295 }
1296 const replayLink = QGroundControl.linkManager.startLogReplay(file)
1297 if (!replayLink) {
1298 QGroundControl.showMessageDialog(
1299 logViewerPage,
1300 qsTr("Log Viewer"),
1301 qsTr("Failed to start telemetry replay for the selected .tlog file.")
1302 )
1303 close()
1304 return
1305 }
1306 replayController.link = replayLink
1307 logViewerController.openTLog(file)
1308 } else {
1309 loadBinFile(file)
1310 }
1311 close()
1312 }
1313 }
1314
1315 Rectangle {
1316 Layout.fillWidth: true
1317 Layout.fillHeight: true
1318 visible: binLoading
1319 color: Qt.rgba(0, 0, 0, 0.4)
1320 z: 5000
1321
1322 Column {
1323 anchors.centerIn: parent
1324 spacing: ScreenTools.defaultFontPixelHeight * 0.5
1325
1326 BusyIndicator {
1327 anchors.horizontalCenter: parent.horizontalCenter
1328 running: binLoading
1329 }
1330
1331 QGCLabel {
1332 text: qsTr("Parsing log file...")
1333 }
1334 }
1335 }
1336
1337 Timer {
1338 id: parseStartTimer
1339 interval: 50
1340 repeat: false
1341 onTriggered: _executePendingBinParse()
1342 }
1343
1344 Timer {
1345 id: fieldSearchTimer
1346 interval: 250
1347 repeat: false
1348 onTriggered: applyFieldFilter()
1349 }
1350
1351 Timer {
1352 id: parameterSearchTimer
1353 interval: 250
1354 repeat: false
1355 onTriggered: applyParameterFilter()
1356 }
1357 }
1358 }
1359}