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)
}
}
}
}
}
}
}
}
}
neiam
/grafana-plasmoid
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
25.4 KiB · text
History
35c3541