25.4 KiB · text History 7a88816
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.components as PlasmaComponents3
import org.kde.kirigami as Kirigami

import "../Grafana.js" as G

// Time series (and the legacy graph panel) drawn on a Canvas: lines,
// area fills, bars or points per the panel's field config, normal or
// percent stacking, threshold lines, a hover crosshair with a tooltip,
// and a legend that isolates a series on click (ctrl-click toggles one).
Item {
    id: ts

    property var frame: null

    readonly property var panel: frame ? frame.panel : ({})
    readonly property var options: panel.options || {}
    readonly property var legendOpts: options.legend || {}
    readonly property bool showLegend: legendOpts.showLegend !== false && legendOpts.displayMode !== "hidden"
    readonly property bool legendRight: legendOpts.placement === "right"
    readonly property var legendCalcs: legendOpts.calcs || []
    readonly property string tooltipMode: (options.tooltip && options.tooltip.mode) || "single"
    readonly property string tooltipSort: (options.tooltip && options.tooltip.sort) || "none"
    readonly property color textColor: Kirigami.Theme.textColor

    // series ready to draw: [{name, times, values, cfg, color}]
    readonly property var prepared: {
        var out = []
        var list = frame ? frame.series : []
        var tc = textColor.toString()
        for (var i = 0; i < list.length; i++) {
            var s = list[i]
            var cfg = G.fieldConfig(panel, s)
            if (cfg.custom && cfg.custom.hideFrom && cfg.custom.hideFrom.viz) continue
            var last = G.reduce(s.values, "lastNotNull")
            out.push({
                name: G.displayName(s, cfg), times: s.times, values: s.values, cfg: cfg,
                color: G.seriesColor(cfg, i, last, { min: cfg.min, max: cfg.max }, tc)
            })
        }
        return out
    }

    // legend state: isolate one, or hide a set
    property var hidden: ({})
    function isShown(name) { return !hidden[name] }
    function legendClick(name, toggle) {
        var h = {}
        if (toggle) {
            h = Object.assign({}, hidden)
            if (h[name]) delete h[name]; else h[name] = true
        } else {
            var isolated = prepared.every(function(s) { return s.name === name ? !hidden[s.name] : !!hidden[s.name] })
            if (!isolated) for (var i = 0; i < prepared.length; i++) if (prepared[i].name !== name) h[prepared[i].name] = true
        }
        hidden = h
    }

    onPreparedChanged: canvas.requestPaint()
    onHiddenChanged: canvas.requestPaint()

    FontMetrics { id: fm; font: Kirigami.Theme.smallFont }

    // ── geometry shared by paint and hover ──────────────────────────────
    property var geo: null

    function nearestIndex(times, t) {
        var lo = 0, hi = times.length - 1
        if (hi < 0) return -1
        while (hi - lo > 1) {
            var mid = (lo + hi) >> 1
            if (times[mid] < t) lo = mid; else hi = mid
        }
        return Math.abs(times[lo] - t) <= Math.abs(times[hi] - t) ? lo : hi
    }

    GridLayout {
        anchors.fill: parent
        flow: ts.legendRight ? GridLayout.LeftToRight : GridLayout.TopToBottom
        rowSpacing: Kirigami.Units.smallSpacing / 2
        columnSpacing: Kirigami.Units.smallSpacing

        Item {
            id: plotArea
            Layout.fillWidth: true
            Layout.fillHeight: true

            Canvas {
                id: canvas
                anchors.fill: parent
                renderStrategy: Canvas.Cooperative

                onWidthChanged: requestPaint()
                onHeightChanged: requestPaint()

                onPaint: {
                    var ctx = getContext("2d")
                    ctx.reset()
                    var W = width, H = height
                    if (W < 20 || H < 20 || !ts.frame) return

                    var range = ts.frame.range
                    var series = ts.prepared.filter(function(s) { return ts.isShown(s.name) })
                    var defaults = (ts.panel.fieldConfig && ts.panel.fieldConfig.defaults) || {}
                    var dcustom = defaults.custom || {}
                    var stackMode = dcustom.stacking && dcustom.stacking.mode ? dcustom.stacking.mode : "none"
                    var unit = defaults.unit
                    var decimals = defaults.decimals

                    // stacked: running sums by timestamp, in series order
                    var ys = []
                    var acc = {}
                    var totals = {}
                    if (stackMode === "percent") {
                        series.forEach(function(s) {
                            for (var i = 0; i < s.times.length; i++) totals[s.times[i]] = (totals[s.times[i]] || 0) + (s.values[i] || 0)
                        })
                    }
                    for (var si = 0; si < series.length; si++) {
                        var s = series[si]
                        var stacked = stackMode !== "none" && !(s.cfg.custom && s.cfg.custom.stacking && s.cfg.custom.stacking.mode === "none")
                        var arr = []
                        var base = []
                        for (var i = 0; i < s.values.length; i++) {
                            var v = s.values[i]
                            if (v === null || v === undefined || isNaN(v)) { arr.push(null); base.push(null); continue }
                            if (stackMode === "percent" && stacked) v = totals[s.times[i]] ? v / totals[s.times[i]] : 0
                            if (stacked) {
                                var b = acc[s.times[i]] || 0
                                base.push(b)
                                acc[s.times[i]] = b + v
                                arr.push(b + v)
                            } else {
                                base.push(0)
                                arr.push(v)
                            }
                        }
                        ys.push({ s: s, y: arr, base: base, stacked: stacked })
                    }

                    // y bounds
                    var lo = Infinity, hi = -Infinity
                    var wantsZero = false
                    ys.forEach(function(e) {
                        var c = e.s.cfg.custom || {}
                        if ((c.fillOpacity || 0) > 0 || c.drawStyle === "bars" || e.stacked) wantsZero = true
                        for (var i = 0; i < e.y.length; i++) {
                            if (e.y[i] === null) continue
                            if (e.y[i] < lo) lo = e.y[i]
                            if (e.y[i] > hi) hi = e.y[i]
                        }
                    })
                    if (!isFinite(lo)) { lo = 0; hi = 1 }
                    if (wantsZero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0) }
                    if (dcustom.axisSoftMin !== undefined && dcustom.axisSoftMin !== null) lo = Math.min(lo, dcustom.axisSoftMin)
                    if (dcustom.axisSoftMax !== undefined && dcustom.axisSoftMax !== null) hi = Math.max(hi, dcustom.axisSoftMax)
                    var fixedMin = defaults.min !== undefined && defaults.min !== null
                    var fixedMax = defaults.max !== undefined && defaults.max !== null
                    if (stackMode === "percent") { lo = 0; hi = 1; unit = "percentunit" }
                    if (fixedMin) lo = defaults.min
                    if (fixedMax) hi = defaults.max

                    var fontPx = Math.max(8, Math.round(fm.height * 0.8))
                    ctx.font = fontPx + "px \"" + Kirigami.Theme.smallFont.family + "\""
                    var plotH0 = H - fontPx - 6
                    var nice = G.niceTicks(lo, hi, Math.max(2, Math.floor(plotH0 / (fontPx * 3))))
                    var yMin = fixedMin ? lo : nice.min
                    var yMax = fixedMax ? hi : nice.max
                    var ticks = nice.ticks.filter(function(t) { return t >= yMin - 1e-9 && t <= yMax + 1e-9 })

                    var labelW = 0
                    var labels = ticks.map(function(t) { var l = G.format(t, unit, decimals); labelW = Math.max(labelW, ctx.measureText(l).width); return l })
                    var showAxis = dcustom.axisPlacement !== "hidden"
                    var L = showAxis ? labelW + 6 : 2, T = fontPx / 2, R = W - 4, B = H - (fontPx + 6)
                    var pw = R - L, ph = B - T
                    if (pw < 10 || ph < 10) return

                    function X(t) { return L + (t - range.from) / (range.to - range.from) * pw }
                    function Y(v) { return B - (v - yMin) / (yMax - yMin || 1) * ph }

                    var grid = Qt.rgba(ts.textColor.r, ts.textColor.g, ts.textColor.b, 0.1)
                    var label = Qt.rgba(ts.textColor.r, ts.textColor.g, ts.textColor.b, 0.65)

                    // grid + y labels
                    ctx.lineWidth = 1
                    ctx.strokeStyle = grid
                    ctx.fillStyle = label
                    ctx.textBaseline = "middle"
                    ctx.textAlign = "right"
                    for (var k = 0; k < ticks.length; k++) {
                        var yy = Math.round(Y(ticks[k])) + 0.5
                        ctx.beginPath(); ctx.moveTo(L, yy); ctx.lineTo(R, yy); ctx.stroke()
                        if (showAxis) ctx.fillText(labels[k], L - 4, yy)
                    }

                    // x labels
                    var tt = G.timeTicks(range.from, range.to, Math.max(2, Math.floor(pw / (fontPx * 6))))
                    ctx.textAlign = "center"
                    ctx.textBaseline = "top"
                    for (var x = 0; x < tt.ticks.length; x++) {
                        var xx = Math.round(X(tt.ticks[x])) + 0.5
                        ctx.beginPath(); ctx.moveTo(xx, T); ctx.lineTo(xx, B); ctx.stroke()
                        ctx.fillText(G.timeLabel(tt.ticks[x], tt.step, range.to - range.from), xx, B + 3)
                    }

                    // thresholds, when the panel asks for them
                    var tstyle = dcustom.thresholdsStyle && dcustom.thresholdsStyle.mode
                    if (tstyle && tstyle !== "off" && defaults.thresholds && defaults.thresholds.steps) {
                        var steps = defaults.thresholds.steps
                        for (var ti = 1; ti < steps.length; ti++) {
                            var tv = steps[ti].value
                            if (tv === null || tv < yMin || tv > yMax) continue
                            var tc = G.namedColor(steps[ti].color, ts.textColor.toString())
                            if (tstyle.indexOf("area") !== -1) {
                                var top = ti + 1 < steps.length && steps[ti + 1].value !== null ? Math.min(steps[ti + 1].value, yMax) : yMax
                                ctx.fillStyle = G.withAlpha(tc, 0.12)
                                ctx.fillRect(L, Y(top), pw, Y(tv) - Y(top))
                            }
                            if (tstyle.indexOf("line") !== -1 || tstyle.indexOf("dashed") !== -1) {
                                ctx.strokeStyle = tc
                                ctx.setLineDash(tstyle.indexOf("dashed") !== -1 ? [4, 4] : [])
                                ctx.beginPath(); ctx.moveTo(L, Y(tv)); ctx.lineTo(R, Y(tv)); ctx.stroke()
                                ctx.setLineDash([])
                            }
                        }
                    }

                    // series, clipped to the plot
                    ctx.save()
                    ctx.beginPath(); ctx.rect(L, T, pw, ph); ctx.clip()

                    var barCount = ys.filter(function(e) { return (e.s.cfg.custom || {}).drawStyle === "bars" && !e.stacked }).length
                    var barSlot = 0
                    for (var e = 0; e < ys.length; e++) {
                        var ent = ys[e]
                        var c = ent.s.cfg.custom || {}
                        var color = ent.s.color
                        var lw = c.lineWidth !== undefined ? c.lineWidth : 1
                        var fill = (c.fillOpacity || 0) / 100
                        var style = c.drawStyle || "line"
                        var interp = c.lineInterpolation || "linear"
                        var span = c.spanNulls === true || (typeof c.spanNulls === "number")
                        var times = ent.s.times
                        var y0 = Y(Math.max(yMin, Math.min(yMax, 0)))

                        if (style === "bars") {
                            var step = times.length > 1 ? (times[times.length - 1] - times[0]) / (times.length - 1) : (range.to - range.from) / 50
                            var bw = Math.max(1, step / (range.to - range.from) * pw * 0.8)
                            var groups = ent.stacked ? 1 : Math.max(1, barCount)
                            var slotW = bw / groups
                            ctx.fillStyle = G.withAlpha(color, Math.max(fill, 0.6))
                            for (var bi = 0; bi < times.length; bi++) {
                                if (ent.y[bi] === null) continue
                                var bx = X(times[bi]) - bw / 2 + (ent.stacked ? 0 : barSlot * slotW)
                                var ytop = Y(ent.y[bi]), ybase = ent.stacked ? Y(ent.base[bi]) : y0
                                ctx.fillRect(bx, Math.min(ytop, ybase), Math.max(1, slotW - 1), Math.abs(ybase - ytop))
                            }
                            if (!ent.stacked) barSlot++
                            continue
                        }

                        // runs of connected points
                        var runs = []
                        var run = []
                        for (var pi = 0; pi < times.length; pi++) {
                            if (ent.y[pi] === null) { if (!span && run.length) { runs.push(run); run = [] } continue }
                            run.push(pi)
                        }
                        if (run.length) runs.push(run)

                        function trace(r, ctx2, forward, ofBase) {
                            for (var q = 0; q < r.length; q++) {
                                var idx = forward ? r[q] : r[r.length - 1 - q]
                                var px = X(times[idx])
                                var py = ofBase ? (ent.stacked ? Y(ent.base[idx]) : y0) : Y(ent.y[idx])
                                if (q === 0) { if (forward) ctx2.moveTo(px, py); else ctx2.lineTo(px, py); continue }
                                var prev = forward ? r[q - 1] : r[r.length - q]
                                var ppy = ofBase ? (ent.stacked ? Y(ent.base[prev]) : y0) : Y(ent.y[prev])
                                if (interp === "stepAfter") ctx2.lineTo(px, ppy)
                                else if (interp === "stepBefore") ctx2.lineTo(X(times[prev]), py)
                                ctx2.lineTo(px, py)
                            }
                        }

                        for (var ri = 0; ri < runs.length; ri++) {
                            var rr = runs[ri]
                            if (fill > 0 && rr.length > 1) {
                                ctx.beginPath()
                                trace(rr, ctx, true, false)
                                trace(rr, ctx, false, true)
                                ctx.closePath()
                                if (c.gradientMode === "opacity" || c.gradientMode === "hue") {
                                    var grad = ctx.createLinearGradient(0, T, 0, B)
                                    grad.addColorStop(0, G.withAlpha(color, Math.min(1, fill * 1.6)))
                                    grad.addColorStop(1, G.withAlpha(color, 0))
                                    ctx.fillStyle = grad
                                } else {
                                    ctx.fillStyle = G.withAlpha(color, fill)
                                }
                                ctx.fill()
                            }
                            if (style === "points" || c.showPoints === "always" || rr.length === 1) {
                                ctx.fillStyle = color
                                var ps = c.pointSize ? c.pointSize / 2 : 2
                                for (var pp = 0; pp < rr.length; pp++) {
                                    ctx.beginPath()
                                    ctx.arc(X(times[rr[pp]]), Y(ent.y[rr[pp]]), ps, 0, Math.PI * 2)
                                    ctx.fill()
                                }
                            }
                            if (style !== "points" && lw > 0 && rr.length > 1) {
                                ctx.beginPath()
                                trace(rr, ctx, true, false)
                                ctx.lineWidth = lw
                                ctx.lineJoin = "round"
                                ctx.strokeStyle = color
                                if (c.lineStyle && c.lineStyle.fill === "dash") ctx.setLineDash(c.lineStyle.dash || [10, 10])
                                ctx.stroke()
                                ctx.setLineDash([])
                            }
                        }
                    }
                    ctx.restore()

                    ts.geo = { L: L, R: R, T: T, B: B, from: range.from, to: range.to, yMin: yMin, yMax: yMax,
                               unit: unit, decimals: decimals, ys: ys }
                }
            }

            // ── hover ───────────────────────────────────────────────────
            MouseArea {
                id: hoverArea
                anchors.fill: parent
                hoverEnabled: true
                acceptedButtons: Qt.NoButton
                property real hx: -1
                property real hy: -1
                onPositionChanged: function(mouse) { hx = mouse.x; hy = mouse.y }
                onExited: hx = -1
            }

            readonly property var hoverInfo: {
                var g = ts.geo
                var hx = hoverArea.hx
                if (!g || hx < g.L || hx > g.R || !hoverArea.containsMouse || ts.tooltipMode === "hidden") return null
                var t = g.from + (hx - g.L) / (g.R - g.L) * (g.to - g.from)
                var rows = []
                var snapT = null
                for (var i = 0; i < g.ys.length; i++) {
                    var e = g.ys[i]
                    var idx = ts.nearestIndex(e.s.times, t)
                    if (idx < 0) continue
                    if (snapT === null) snapT = e.s.times[idx]
                    var raw = e.s.values[idx]
                    rows.push({ name: e.s.name, color: e.s.color, raw: raw,
                                y: e.y[idx] === null ? null : g.B - (e.y[idx] - g.yMin) / (g.yMax - g.yMin || 1) * (g.B - g.T),
                                text: G.format(raw, e.s.cfg.unit, e.s.cfg.decimals) })
                }
                if (snapT === null) return null
                if (ts.tooltipMode === "single" && rows.length > 1) {
                    var best = null
                    for (var j = 0; j < rows.length; j++) {
                        if (rows[j].y === null) continue
                        if (!best || Math.abs(rows[j].y - hoverArea.hy) < Math.abs(best.y - hoverArea.hy)) best = rows[j]
                    }
                    rows = best ? [best] : []
                }
                if (ts.tooltipSort === "desc") rows.sort(function(a, b) { return (b.raw || 0) - (a.raw || 0) })
                if (ts.tooltipSort === "asc") rows.sort(function(a, b) { return (a.raw || 0) - (b.raw || 0) })
                return { x: g.L + (snapT - g.from) / (g.to - g.from) * (g.R - g.L), time: snapT, rows: rows.slice(0, 12), more: Math.max(0, rows.length - 12) }
            }

            Rectangle {
                visible: !!plotArea.hoverInfo
                x: plotArea.hoverInfo ? Math.round(plotArea.hoverInfo.x) : 0
                y: ts.geo ? ts.geo.T : 0
                width: 1
                height: ts.geo ? ts.geo.B - ts.geo.T : 0
                color: Kirigami.Theme.textColor
                opacity: 0.4
            }

            Rectangle {
                id: tip
                visible: !!plotArea.hoverInfo && plotArea.hoverInfo.rows.length > 0
                readonly property real wanted: plotArea.hoverInfo ? plotArea.hoverInfo.x + 12 : 0
                x: wanted + width > plotArea.width ? Math.max(0, (plotArea.hoverInfo ? plotArea.hoverInfo.x : 0) - width - 12) : wanted
                y: Math.max(0, Math.min(plotArea.height - height, hoverArea.hy - height / 2))
                z: 10
                width: Math.min(plotArea.width, tipColumn.implicitWidth + Kirigami.Units.smallSpacing * 2)
                height: tipColumn.implicitHeight + Kirigami.Units.smallSpacing * 2
                radius: Kirigami.Units.cornerRadius
                color: Kirigami.Theme.backgroundColor
                border.color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.25)

                Column {
                    id: tipColumn
                    x: Kirigami.Units.smallSpacing
                    y: Kirigami.Units.smallSpacing

                    PlasmaComponents3.Label {
                        font: Kirigami.Theme.smallFont
                        opacity: 0.7
                        text: plotArea.hoverInfo ? G.fullTime(plotArea.hoverInfo.time) : ""
                    }

                    Repeater {
                        model: plotArea.hoverInfo ? plotArea.hoverInfo.rows : []
                        delegate: Row {
                            required property var modelData
                            spacing: Kirigami.Units.smallSpacing
                            Rectangle { width: 10; height: 3; radius: 1; color: modelData.color; anchors.verticalCenter: parent.verticalCenter }
                            PlasmaComponents3.Label {
                                font: Kirigami.Theme.smallFont
                                text: modelData.name
                                elide: Text.ElideRight
                                width: Math.min(implicitWidth, Kirigami.Units.gridUnit * 14)
                            }
                            PlasmaComponents3.Label { font.pointSize: Kirigami.Theme.smallFont.pointSize; font.bold: true; text: modelData.text }
                        }
                    }

                    PlasmaComponents3.Label {
                        visible: plotArea.hoverInfo && plotArea.hoverInfo.more > 0
                        font: Kirigami.Theme.smallFont
                        opacity: 0.7
                        text: plotArea.hoverInfo ? i18n("+%1 more", plotArea.hoverInfo.more) : ""
                    }
                }
            }
        }

        // ── legend ──────────────────────────────────────────────────────
        Flickable {
            visible: ts.showLegend && ts.prepared.length > 0
            Layout.fillWidth: !ts.legendRight
            Layout.preferredWidth: ts.legendRight ? Math.min(legendFlow.implicitWidth, ts.width * 0.4) : -1
            Layout.fillHeight: ts.legendRight
            Layout.preferredHeight: ts.legendRight ? -1 : Math.min(legendFlow.implicitHeight, ts.height * 0.35)
            contentHeight: legendFlow.implicitHeight
            contentWidth: width
            clip: true
            boundsBehavior: Flickable.StopAtBounds

            Flow {
                id: legendFlow
                width: parent.width
                spacing: Kirigami.Units.smallSpacing
                flow: ts.legendRight || ts.legendOpts.displayMode === "table" ? Flow.TopToBottom : Flow.LeftToRight

                Repeater {
                    model: ts.prepared

                    delegate: MouseArea {
                        id: entry
                        required property var modelData
                        width: entryRow.implicitWidth
                        height: entryRow.implicitHeight
                        hoverEnabled: true
                        cursorShape: Qt.PointingHandCursor
                        onClicked: function(mouse) { ts.legendClick(modelData.name, mouse.modifiers & Qt.ControlModifier) }
                        opacity: ts.isShown(modelData.name) ? 1 : 0.4

                        Row {
                            id: entryRow
                            spacing: Kirigami.Units.smallSpacing / 2
                            Rectangle { width: 12; height: 3; radius: 1; color: entry.modelData.color; anchors.verticalCenter: parent.verticalCenter }
                            PlasmaComponents3.Label {
                                font: Kirigami.Theme.smallFont
                                text: entry.modelData.name
                                elide: Text.ElideRight
                                width: Math.min(implicitWidth, Kirigami.Units.gridUnit * 16)
                                textFormat: Text.PlainText
                            }
                            Repeater {
                                model: ts.legendCalcs
                                delegate: PlasmaComponents3.Label {
                                    required property string modelData
                                    font: Kirigami.Theme.smallFont
                                    opacity: 0.75
                                    text: " " + G.calcLabel(modelData) + ": "
                                          + G.format(G.reduce(entry.modelData.values, modelData), entry.modelData.cfg.unit, entry.modelData.cfg.decimals)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}