7import QGroundControl.Controls
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).
16 required property var logParser
17 required property var logViewerController
19 property bool xAxisShowLocalTime: false
21 spacing: ScreenTools.defaultFontPixelHeight * 0.5
23 // -------------------------------------------------------------------------
25 // -------------------------------------------------------------------------
26 property var _markerRows: []
27 property var _markerEventRows: []
28 property string _markerModeName: ""
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: ({})
35 // Shared position / zoom signals
36 signal cursorMoved(real timestampSeconds)
37 signal zoomApplied(real minX, real maxX)
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
44 readonly property var _fieldChartColors: [
54 "#546E7A", // blue grey
59 // -------------------------------------------------------------------------
61 // -------------------------------------------------------------------------
63 id: _lineSeriesComponent
68 id: _scatterSeriesComponent
72 // -------------------------------------------------------------------------
73 // Color helpers (public — parent may call fieldColor() for the field list)
74 // -------------------------------------------------------------------------
75 function eventColor(eventType) {
76 return logViewerController.eventColor(eventType)
79 function modeColor(modeName) {
80 return logParser.modeColor(String(modeName))
83 function modeLegendEntries() {
84 return logParser.modeNames
87 function fieldColor(fieldName) {
88 const idx = logViewerController.selectedFields.indexOf(fieldName)
90 return _fieldChartColors[idx % _fieldChartColors.length]
92 return logViewerController.fieldColor(fieldName)
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")
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() }
111 // -------------------------------------------------------------------------
113 // -------------------------------------------------------------------------
114 function _queryValuesAtTime(xValue) {
115 const modeName = logParser.modeAt(xValue)
116 const selectedFields = logViewerController.selectedFields
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]
125 color: fieldColor(field),
127 min: fr ? fr.min : NaN,
128 max: fr ? fr.max : NaN
131 const threshold = Math.max(0.05, (_base.xAxis.max - _base.xAxis.min) / 200.0)
132 const nearbyEvents = logParser.eventsNear(xValue, threshold)
134 for (let i = 0; i < nearbyEvents.length; i++) {
135 events.push({ color: eventColor(nearbyEvents[i].type), text: nearbyEvents[i].description })
137 return { modeName: modeName, rows: rows, events: events }
140 function _queryCursorValues() {
141 const result = _queryValuesAtTime(_base.markerXValue)
142 _markerModeName = result.modeName
143 _markerRows = result.rows
144 _markerEventRows = result.events
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)
153 // Public: called by parent on log clear
154 function clearMarker() {
158 _markerEventRows = []
161 // -------------------------------------------------------------------------
162 // Chart / series management
163 // -------------------------------------------------------------------------
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])
173 _eventSeriesByType = {}
175 const hasRange = logParser.minTimestamp >= 0.0 && logParser.maxTimestamp > logParser.minTimestamp
176 _base.initRange(hasRange ? logParser.minTimestamp : 0,
177 hasRange ? logParser.maxTimestamp : 1)
182 function _syncSeriesWithSelection() {
183 const newSelection = logViewerController.selectedFields
185 // Remove series no longer in selection
187 for (let i = 0; i < newSelection.length; i++) {
188 desired[String(newSelection[i])] = true
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]]
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])
205 const pixelWidth = Math.max(1, Math.floor(_base.graphsView.plotArea.width))
206 const points = logParser.fieldSamplesFiltered(fieldName, _base.zoomMinX, _base.zoomMaxX, pixelWidth)
209 if (_seriesByField[fieldName]) {
210 series = _seriesByField[fieldName]
213 series = _lineSeriesComponent.createObject(_base.graphsView, {
214 color: fieldColor(fieldName),
219 _base.graphsView.addSeries(series)
220 _seriesByField[fieldName] = series
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
227 if (!points || points.length === 0) {
228 _fieldYRange[fieldName] = { min: 0, max: 1 }
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
239 _fieldYRange[fieldName] = { min: minY, max: maxY }
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)
248 const allTracked = Object.keys(_seriesByField)
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]]
257 if (r.min < globalMinY) globalMinY = r.min
258 if (r.max > globalMaxY) globalMaxY = r.max
261 if (globalMinY === globalMaxY) globalMaxY = globalMinY + 1
263 _base.yAxis.min = globalMinY
264 _base.yAxis.max = globalMaxY
266 const existingTypes = Object.keys(_eventSeriesByType)
267 for (let i = 0; i < existingTypes.length; i++) {
268 _base.graphsView.removeSeries(_eventSeriesByType[existingTypes[i]])
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),
281 _base.graphsView.addSeries(eventSeries)
282 _eventSeriesByType[ev.type] = eventSeries
284 _eventSeriesByType[ev.type].append(ev.time, _base.yAxis.max)
287 if (_base.markerVisible) {
288 Qt.callLater(_queryCursorValues)
292 // -------------------------------------------------------------------------
293 // Internal connections
294 // -------------------------------------------------------------------------
296 target: logViewerController
297 function onSelectedFieldsChanged() {
298 Qt.callLater(_syncSeriesWithSelection)
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) }
310 // -------------------------------------------------------------------------
311 // Timeline bars (offset to align with chart plot area)
312 // -------------------------------------------------------------------------
314 id: _timelineContainer
315 Layout.preferredWidth: _base.graphsView.plotArea.x + _base.graphsView.plotArea.width
316 spacing: ScreenTools.defaultFontPixelHeight * 0.1
318 property real _barHeight: ScreenTools.defaultFontPixelHeight * 0.6
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))
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
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))
338 // Bar 1: Flight modes
340 Layout.fillWidth: true
341 visible: logParser.modeSegments.length > 0
346 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
347 Layout.maximumWidth: Layout.preferredWidth
351 Layout.fillWidth: true
352 Layout.preferredHeight: _timelineContainer._barHeight
355 model: logParser.modeSegments
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))
370 Layout.fillWidth: true
371 visible: logParser.dropouts.length > 0
374 text: qsTr("Dropouts")
376 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
377 Layout.maximumWidth: Layout.preferredWidth
381 Layout.fillWidth: true
382 Layout.preferredHeight: _timelineContainer._barHeight
385 model: logParser.dropouts
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))
400 Layout.fillWidth: true
401 visible: logParser.events.length > 0
406 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
407 Layout.maximumWidth: Layout.preferredWidth
411 Layout.fillWidth: true
412 Layout.preferredHeight: _timelineContainer._barHeight
415 model: logParser.events
419 height: parent.height
420 visible: _base.xAxis.max > _base.xAxis.min
421 color: eventColor(modelData.type)
422 x: _timelineContainer._eventX(modelData.time, width)
429 // -------------------------------------------------------------------------
431 // -------------------------------------------------------------------------
434 Layout.fillWidth: true
435 Layout.fillHeight: true
436 logParser: control.logParser
437 xAxisShowLocalTime: control.xAxisShowLocalTime
438 yAxisTitle: qsTr("Value")
440 // ---- Chart-specific popup rows ----
442 visible: _markerModeName.length > 0
443 spacing: ScreenTools.defaultFontPixelWidth * 0.2
446 Layout.preferredWidth: _base.colorBlockWidth
447 Layout.preferredHeight: _base.colorBlockWidth
448 color: modeColor(_markerModeName)
451 QGCLabel { text: qsTr("Mode:") }
452 QGCLabel { text: _markerModeName; font.bold: true }
459 spacing: ScreenTools.defaultFontPixelHeight * 0.15
462 spacing: ScreenTools.defaultFontPixelWidth * 0.4
465 Layout.preferredWidth: _base.colorBlockWidth
466 Layout.preferredHeight: _base.colorBlockWidth
467 color: modelData.color
471 width: _base.popupWidth - (ScreenTools.defaultFontPixelWidth * 4)
472 elide: Text.ElideMiddle
479 Layout.leftMargin: _base.colorBlockWidth + ScreenTools.defaultFontPixelWidth * 0.4
480 spacing: ScreenTools.defaultFontPixelWidth * 0.3
482 QGCLabel { text: qsTr("Current") }
483 QGCLabel { text: Number(modelData.value).toFixed(3); font.bold: true }
485 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
487 QGCLabel { text: qsTr("Min") }
488 QGCLabel { text: isNaN(modelData.min) ? "—" : Number(modelData.min).toFixed(3); font.bold: true }
490 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
492 QGCLabel { text: qsTr("Max") }
493 QGCLabel { text: isNaN(modelData.max) ? "—" : Number(modelData.max).toFixed(3); font.bold: true }
499 model: _markerEventRows
502 spacing: ScreenTools.defaultFontPixelWidth * 0.2
505 Layout.preferredWidth: _base.colorBlockWidth
506 Layout.preferredHeight: _base.colorBlockWidth
507 color: modelData.color
511 Layout.maximumWidth: ScreenTools.defaultFontPixelWidth * 20
512 wrapMode: Text.WordWrap
520 // -------------------------------------------------------------------------
522 // -------------------------------------------------------------------------
524 Layout.fillWidth: true
525 Layout.preferredHeight: _legendRowHeight
526 visible: modeLegendEntries().length > 0
527 spacing: ScreenTools.defaultFontPixelWidth
529 QGCLabel { text: qsTr("Modes:"); font.bold: true }
532 model: modeLegendEntries()
535 spacing: _legendItemSpacing
538 Layout.preferredWidth: _legendColorBlockSize
539 Layout.preferredHeight: _legendColorBlockSize
540 color: modeColor(modelData)
543 QGCLabel { text: modelData }
549 Layout.fillWidth: true
550 Layout.preferredHeight: _legendRowHeight
551 visible: logParser.events.length > 0
552 spacing: ScreenTools.defaultFontPixelWidth
554 QGCLabel { text: qsTr("Events:"); font.bold: true }
557 model: ["mode", "event", "error", "warning"]
560 spacing: _legendItemSpacing
562 for (let i = 0; i < logParser.events.length; i++) {
563 if (logParser.events[i].type === modelData) return true
569 Layout.preferredWidth: _legendColorBlockSize
570 Layout.preferredHeight: _legendColorBlockSize
571 color: eventColor(modelData)
574 QGCLabel { text: eventTypeLabel(modelData) }
579 // -------------------------------------------------------------------------
581 // -------------------------------------------------------------------------
583 Layout.fillWidth: true
584 spacing: ScreenTools.defaultFontPixelWidth
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.")
592 text: qsTr("Reset Zoom")
593 enabled: _base.zoomMinX !== _base.fullMinX || _base.zoomMaxX !== _base.fullMaxX
594 onClicked: _base.resetZoom()