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),
217 _base.graphsView.addSeries(series)
218 _seriesByField[fieldName] = series
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
225 if (!points || points.length === 0) {
226 _fieldYRange[fieldName] = { min: 0, max: 1 }
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
237 _fieldYRange[fieldName] = { min: minY, max: maxY }
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)
246 const allTracked = Object.keys(_seriesByField)
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]]
255 if (r.min < globalMinY) globalMinY = r.min
256 if (r.max > globalMaxY) globalMaxY = r.max
259 if (globalMinY === globalMaxY) globalMaxY = globalMinY + 1
261 _base.yAxis.min = globalMinY
262 _base.yAxis.max = globalMaxY
264 const existingTypes = Object.keys(_eventSeriesByType)
265 for (let i = 0; i < existingTypes.length; i++) {
266 _base.graphsView.removeSeries(_eventSeriesByType[existingTypes[i]])
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)
277 _base.graphsView.addSeries(eventSeries)
278 _eventSeriesByType[ev.type] = eventSeries
280 _eventSeriesByType[ev.type].append(ev.time, _base.yAxis.max)
283 if (_base.markerVisible) {
284 Qt.callLater(_queryCursorValues)
288 // -------------------------------------------------------------------------
289 // Internal connections
290 // -------------------------------------------------------------------------
292 target: logViewerController
293 function onSelectedFieldsChanged() {
294 Qt.callLater(_syncSeriesWithSelection)
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) }
306 // -------------------------------------------------------------------------
307 // Timeline bars (offset to align with chart plot area)
308 // -------------------------------------------------------------------------
310 id: _timelineContainer
311 Layout.preferredWidth: _base.graphsView.plotArea.x + _base.graphsView.plotArea.width
312 spacing: ScreenTools.defaultFontPixelHeight * 0.1
314 property real _barHeight: ScreenTools.defaultFontPixelHeight * 0.6
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))
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
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))
334 // Bar 1: Flight modes
336 Layout.fillWidth: true
337 visible: logParser.modeSegments.length > 0
342 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
343 Layout.maximumWidth: Layout.preferredWidth
347 Layout.fillWidth: true
348 Layout.preferredHeight: _timelineContainer._barHeight
351 model: logParser.modeSegments
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))
366 Layout.fillWidth: true
367 visible: logParser.dropouts.length > 0
370 text: qsTr("Dropouts")
372 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
373 Layout.maximumWidth: Layout.preferredWidth
377 Layout.fillWidth: true
378 Layout.preferredHeight: _timelineContainer._barHeight
381 model: logParser.dropouts
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))
396 Layout.fillWidth: true
397 visible: logParser.events.length > 0
402 Layout.preferredWidth: _base.graphsView.plotArea.x // Align with chart plot area
403 Layout.maximumWidth: Layout.preferredWidth
407 Layout.fillWidth: true
408 Layout.preferredHeight: _timelineContainer._barHeight
411 model: logParser.events
415 height: parent.height
416 visible: _base.xAxis.max > _base.xAxis.min
417 color: eventColor(modelData.type)
418 x: _timelineContainer._eventX(modelData.time, width)
425 // -------------------------------------------------------------------------
427 // -------------------------------------------------------------------------
430 Layout.fillWidth: true
431 Layout.fillHeight: true
432 logParser: control.logParser
433 xAxisShowLocalTime: control.xAxisShowLocalTime
434 yAxisTitle: qsTr("Value")
436 // ---- Chart-specific popup rows ----
438 visible: _markerModeName.length > 0
439 spacing: ScreenTools.defaultFontPixelWidth * 0.2
442 Layout.preferredWidth: _base.colorBlockWidth
443 Layout.preferredHeight: _base.colorBlockWidth
444 color: modeColor(_markerModeName)
447 QGCLabel { text: qsTr("Mode:") }
448 QGCLabel { text: _markerModeName; font.bold: true }
455 spacing: ScreenTools.defaultFontPixelHeight * 0.15
458 spacing: ScreenTools.defaultFontPixelWidth * 0.4
461 Layout.preferredWidth: _base.colorBlockWidth
462 Layout.preferredHeight: _base.colorBlockWidth
463 color: modelData.color
467 width: _base.popupWidth - (ScreenTools.defaultFontPixelWidth * 4)
468 elide: Text.ElideMiddle
475 Layout.leftMargin: _base.colorBlockWidth + ScreenTools.defaultFontPixelWidth * 0.4
476 spacing: ScreenTools.defaultFontPixelWidth * 0.3
478 QGCLabel { text: qsTr("Current") }
479 QGCLabel { text: Number(modelData.value).toFixed(3); font.bold: true }
481 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
483 QGCLabel { text: qsTr("Min") }
484 QGCLabel { text: isNaN(modelData.min) ? "—" : Number(modelData.min).toFixed(3); font.bold: true }
486 Item { width: ScreenTools.defaultFontPixelWidth * 0.5 }
488 QGCLabel { text: qsTr("Max") }
489 QGCLabel { text: isNaN(modelData.max) ? "—" : Number(modelData.max).toFixed(3); font.bold: true }
495 model: _markerEventRows
498 spacing: ScreenTools.defaultFontPixelWidth * 0.2
501 Layout.preferredWidth: _base.colorBlockWidth
502 Layout.preferredHeight: _base.colorBlockWidth
503 color: modelData.color
507 Layout.maximumWidth: ScreenTools.defaultFontPixelWidth * 20
508 wrapMode: Text.WordWrap
516 // -------------------------------------------------------------------------
518 // -------------------------------------------------------------------------
520 Layout.fillWidth: true
521 Layout.preferredHeight: _legendRowHeight
522 visible: modeLegendEntries().length > 0
523 spacing: ScreenTools.defaultFontPixelWidth
525 QGCLabel { text: qsTr("Modes:"); font.bold: true }
528 model: modeLegendEntries()
531 spacing: _legendItemSpacing
534 Layout.preferredWidth: _legendColorBlockSize
535 Layout.preferredHeight: _legendColorBlockSize
536 color: modeColor(modelData)
539 QGCLabel { text: modelData }
545 Layout.fillWidth: true
546 Layout.preferredHeight: _legendRowHeight
547 visible: logParser.events.length > 0
548 spacing: ScreenTools.defaultFontPixelWidth
550 QGCLabel { text: qsTr("Events:"); font.bold: true }
553 model: ["mode", "event", "error", "warning"]
556 spacing: _legendItemSpacing
558 for (let i = 0; i < logParser.events.length; i++) {
559 if (logParser.events[i].type === modelData) return true
565 Layout.preferredWidth: _legendColorBlockSize
566 Layout.preferredHeight: _legendColorBlockSize
567 color: eventColor(modelData)
570 QGCLabel { text: eventTypeLabel(modelData) }
575 // -------------------------------------------------------------------------
577 // -------------------------------------------------------------------------
579 Layout.fillWidth: true
580 spacing: ScreenTools.defaultFontPixelWidth
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.")
588 text: qsTr("Reset Zoom")
589 enabled: _base.zoomMinX !== _base.fullMinX || _base.zoomMaxX !== _base.fullMaxX
590 onClicked: _base.resetZoom()