pragma ComponentBehavior: Bound import QtQuick Item { id: root required property list values property int xSteps: 24 property real yMin: NaN property real yMax: NaN property real yPaddingTop: 0 property real yPaddingBottom: 0 property int verticalLineStep: 0 property int horizontalLineStep: 0 property bool firstXLine: true property bool lastXLine: false property int minHorizontalLines: 0 property var xLabel: null property var yLabel: null property real smoothing: 0 property bool curveSmoothing: true property real markerX: -1 property real markerGap: 2 // px gap between line and marker dot edge, each side property color lineColor: "white" property color gridColor: "white" property color markerColor: "white" property color labelColor: "white" property real gridAlpha: 0.12 property real labelAlpha: 0.6 property real fillAlpha: 0.15 property real lineWidth: 2 property real markerDotRadius: 4 property real labelFontSize: 10 Canvas { id: canvas anchors.fill: parent renderStrategy: Canvas.Immediate onWidthChanged: requestPaint() onHeightChanged: requestPaint() function strokeCurve(ctx, pts) { ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); if (root.curveSmoothing && pts.length > 2) { const k = 1 / 6; const n = pts.length; for (let i = 0; i < n - 1; i++) { const p0 = pts[Math.max(i - 1, 0)]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[Math.min(i + 2, n - 1)]; let c1x = p1.x + (p2.x - p0.x) * k; let c1y = p1.y + (p2.y - p0.y) * k; let c2x = p2.x - (p3.x - p1.x) * k; let c2y = p2.y - (p3.y - p1.y) * k; const minY = Math.min(p1.y, p2.y); const maxY = Math.max(p1.y, p2.y); c1y = Math.max(minY, Math.min(maxY, c1y)); c2y = Math.max(minY, Math.min(maxY, c2y)); ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); } } else { for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i]; const p1 = pts[i + 1]; const cx = (p0.x + p1.x) * 0.5; ctx.bezierCurveTo(cx, p0.y, cx, p1.y, p1.x, p1.y); } } } // Control points for segment i (pts[i] -> pts[i+1]), matching strokeCurve function segmentControls(pts, i) { const p0 = pts[Math.max(i - 1, 0)]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[Math.min(i + 2, pts.length - 1)]; if (root.curveSmoothing && pts.length > 2) { const k = 1 / 6; let c1x = p1.x + (p2.x - p0.x) * k; let c1y = p1.y + (p2.y - p0.y) * k; let c2x = p2.x - (p3.x - p1.x) * k; let c2y = p2.y - (p3.y - p1.y) * k; const minY = Math.min(p1.y, p2.y); const maxY = Math.max(p1.y, p2.y); c1y = Math.max(minY, Math.min(maxY, c1y)); c2y = Math.max(minY, Math.min(maxY, c2y)); return [c1x, c1y, c2x, c2y]; } const cx = (p1.x + p2.x) * 0.5; return [cx, p1.y, cx, p2.y]; } // Point on the curve at a given x (x is monotonic per segment) function pointAtX(pts, targetX) { let j = pts.length - 2; for (let i = 0; i < pts.length - 1; i++) { if (targetX <= pts[i + 1].x) { j = i; break; } } const p1 = pts[j]; const p2 = pts[j + 1]; const [c1x, c1y, c2x, c2y] = segmentControls(pts, j); let tlo = 0; let thi = 1; for (let it = 0; it < 24; it++) { const tm = (tlo + thi) / 2; const u = 1 - tm; const x = u * u * u * p1.x + 3 * u * u * tm * c1x + 3 * u * tm * tm * c2x + tm * tm * tm * p2.x; if (x < targetX) tlo = tm; else thi = tm; } const t = (tlo + thi) / 2; const u = 1 - t; const y = u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y; return { x: targetX, y }; } // Part of the curve left/right of a cut x, including the cut point function cutLeft(pts, targetX) { if (targetX <= pts[0].x) return []; if (targetX >= pts[pts.length - 1].x) return pts.slice(); let j = pts.length - 2; for (let i = 0; i < pts.length - 1; i++) { if (targetX <= pts[i + 1].x) { j = i; break; } } const left = pts.slice(0, j + 1); if (targetX > pts[j].x) left.push(pointAtX(pts, targetX)); return left; } function cutRight(pts, targetX) { if (targetX >= pts[pts.length - 1].x) return []; if (targetX <= pts[0].x) return pts.slice(); let j = pts.length - 2; for (let i = 0; i < pts.length - 1; i++) { if (targetX <= pts[i + 1].x) { j = i; break; } } const right = pts.slice(j + 1); if (targetX < pts[j + 1].x) right.unshift(pointAtX(pts, targetX)); return right; } onPaint: { const ctx = getContext("2d"); ctx.reset(); const W = width; const H = height; if (W < 2 || H < 2) return; const padLeft = root.labelFontSize * 3.4; const padRight = 2; const padTop = 2; const padBottom = root.labelFontSize + 8; const plot = { x: padLeft, y: padTop, w: W - padLeft - padRight, h: H - padTop - padBottom }; if (plot.w < 2 || plot.h < 2) return; // Auto-fit: NaN yMin/yMax derive from the data, snapped to the // horizontal step grid, honoring minHorizontalLines let lo = root.yMin; let hi = root.yMax; const loAuto = Number.isNaN(lo); const hiAuto = Number.isNaN(hi); if (loAuto || hiAuto) { let dmin = Infinity; let dmax = -Infinity; for (const v of root.values) { if (v == null) continue; if (v < dmin) dmin = v; if (v > dmax) dmax = v; } if (dmin === Infinity) { dmin = 0; dmax = 1; } const step = root.horizontalLineStep; const minSteps = root.minHorizontalLines > 1 ? root.minHorizontalLines - 1 : 0; if (step > 0) { if (hiAuto) { const baseLo = loAuto ? Math.floor(dmin / step) * step : lo; const span = Math.max(Math.ceil((dmax - baseLo) / step), minSteps, 1); lo = baseLo; hi = baseLo + span * step; } else { const span = Math.max(Math.ceil((hi - dmin) / step), minSteps, 1); lo = hi - span * step; } } else { if (loAuto) lo = dmin; if (hiAuto) hi = dmax; if (hi <= lo) hi = lo + 1; } } const scaleBottom = lo - root.yPaddingBottom; const scaleTop = hi + root.yPaddingTop; const range = scaleTop - scaleBottom; if (range <= 0 || root.xSteps < 1) return; let series = root.values; if (root.smoothing > 0 && series.length > 0) { const radius = Math.round(root.smoothing); const raw = series; series = raw.map((v, i) => { if (v == null) return null; let sum = 0; let n = 0; for (let j = -radius; j <= radius; j++) { const u = raw[i + j]; if (u != null) { sum += u; n++; } } return n > 0 ? sum / n : null; }); } const xAt = i => plot.x + (i / root.xSteps) * plot.w; const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h; const crisp = v => Math.floor(v) + 0.5; // 1. Grid lines ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha); ctx.lineWidth = 1; if (root.verticalLineStep > 0) { for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { if (i === 0 && !root.firstXLine) continue; if (i === root.xSteps && !root.lastXLine) continue; const x = crisp(xAt(i)); ctx.beginPath(); ctx.moveTo(x, plot.y); ctx.lineTo(x, H); ctx.stroke(); } } // Lines extend down into yPaddingBottom, snapped to the grid const vStart = lo - (root.yPaddingBottom > 0 ? Math.floor(root.yPaddingBottom / root.horizontalLineStep) * root.horizontalLineStep : 0); if (root.horizontalLineStep > 0) { for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { const y = crisp(yAt(v)); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(plot.x + plot.w, y); ctx.stroke(); } } // 2. Labels ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha); ctx.font = root.labelFontSize + "px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "top"; if (root.verticalLineStep > 0 && root.xLabel) { for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { if (i === 0 && !root.firstXLine) continue; if (i === root.xSteps && !root.lastXLine) continue; const text = root.xLabel(i); if (text) ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 4); } } ctx.textBaseline = "bottom"; if (root.horizontalLineStep > 0 && root.yLabel) { for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { const text = root.yLabel(v); if (text) ctx.fillText(text, 2, yAt(v) - 2); } } // 3. Curve, split into segments at null gaps const segments = []; let current = null; for (let i = 0; i < series.length; i++) { if (series[i] == null) { if (current) segments.push(current); current = null; continue; } const pt = { x: xAt(i), y: yAt(series[i]) }; if (!current) current = []; current.push(pt); } if (current) segments.push(current); ctx.lineCap = "round"; ctx.lineJoin = "round"; const hasMarker = root.markerX >= 0 && root.markerX <= root.xSteps; const markerPx = hasMarker ? xAt(root.markerX) : 0; let gapPx = 0; if (hasMarker) { // markerGap is measured from the dot edge, perpendicular to the // curve; project onto x via the local curve slope let slope = 0; const seg = segments.find(s => s[0].x <= markerPx && markerPx <= s[s.length - 1].x); if (seg && seg.length > 2 && markerPx - 1 >= seg[0].x && markerPx + 1 <= seg[seg.length - 1].x) slope = (pointAtX(seg, markerPx + 1).y - pointAtX(seg, markerPx - 1).y) / 2; gapPx = (root.markerDotRadius + root.markerGap) / Math.sqrt(1 + slope * slope); } for (const seg of segments) { if (seg.length < 2) continue; // Fill stays continuous under the marker ctx.fillStyle = Qt.alpha(root.lineColor, root.fillAlpha); strokeCurve(ctx, seg); ctx.lineTo(seg[seg.length - 1].x, plot.y + plot.h); ctx.lineTo(seg[0].x, plot.y + plot.h); ctx.closePath(); ctx.fill(); // Stroke has a gap around the marker const pieces = hasMarker ? [cutLeft(seg, markerPx - gapPx), cutRight(seg, markerPx + gapPx)] : [seg]; for (const pts of pieces) { if (pts.length < 2) continue; ctx.strokeStyle = root.lineColor; ctx.lineWidth = root.lineWidth; strokeCurve(ctx, pts); ctx.stroke(); } } // 4. Marker: dashed vertical + dot if (root.markerX >= 0 && root.markerX <= root.xSteps) { const x = markerPx; ctx.setLineDash([4, 4]); ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(crisp(x), plot.y); ctx.lineTo(crisp(x), H); ctx.stroke(); ctx.setLineDash([]); const i0 = Math.floor(root.markerX); const v0 = series[i0]; const v1 = series[i0 + 1]; let y = null; if (v0 != null && v1 != null) y = yAt(v0 + (v1 - v0) * (root.markerX - i0)); else if (v0 != null) y = yAt(v0); if (y != null) { ctx.fillStyle = root.markerColor; ctx.beginPath(); ctx.arc(x, y, root.markerDotRadius, 0, Math.PI * 2); ctx.fill(); } } } } onValuesChanged: canvas.requestPaint() onXStepsChanged: canvas.requestPaint() onYMinChanged: canvas.requestPaint() onYMaxChanged: canvas.requestPaint() onYPaddingTopChanged: canvas.requestPaint() onYPaddingBottomChanged: canvas.requestPaint() onVerticalLineStepChanged: canvas.requestPaint() onHorizontalLineStepChanged: canvas.requestPaint() onFirstXLineChanged: canvas.requestPaint() onLastXLineChanged: canvas.requestPaint() onMinHorizontalLinesChanged: canvas.requestPaint() onXLabelChanged: canvas.requestPaint() onYLabelChanged: canvas.requestPaint() onSmoothingChanged: canvas.requestPaint() onCurveSmoothingChanged: canvas.requestPaint() onMarkerXChanged: canvas.requestPaint() onMarkerGapChanged: canvas.requestPaint() onLineColorChanged: canvas.requestPaint() onGridColorChanged: canvas.requestPaint() onMarkerColorChanged: canvas.requestPaint() onLabelColorChanged: canvas.requestPaint() onGridAlphaChanged: canvas.requestPaint() onLabelAlphaChanged: canvas.requestPaint() onFillAlphaChanged: canvas.requestPaint() onLineWidthChanged: canvas.requestPaint() onMarkerDotRadiusChanged: canvas.requestPaint() onLabelFontSizeChanged: canvas.requestPaint() }