feat: graphs now properly sized + port static graphs to c++

This commit is contained in:
2026-11-08 01:28:27 +01:00
parent a7767d2a80
commit d69a6bcafd
17 changed files with 1592 additions and 1257 deletions
-506
View File
@@ -1,506 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtCanvas2D
import ZShell
Item {
id: root
required property list<var> 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: 8
Canvas2D {
id: canvas
anchors.fill: parent
alphaBlending: true
fillColor: "transparent"
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
function snapV(v) {
return ZUtils.snap(v, canvas);
}
// A value is plottable only if it is a real number. Everything else -
// null, undefined, NaN, an unparsable string from the API - is a hole, so
// the series splits there. Non-finite coordinates must never reach the
// path: one NaN lineTo makes the canvas ignore the rest of the curve, and
// the fill then runs from whatever y it last had straight down.
function numeric(v) {
if (typeof v === "number")
return Number.isFinite(v) ? v : null;
if (typeof v === "string") {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
function strokeCurve(ctx, pts) {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
const dpr = ZUtils.dprFor(canvas);
for (let i = 0; i < pts.length - 1; i++) {
const p1 = pts[i];
const p2 = pts[i + 1];
const [c1x, c1y, c2x, c2y] = segmentControls(pts, i);
const firstCol = Math.ceil(p1.x * dpr) + 1;
const lastCol = Math.floor(p2.x * dpr) - 1;
for (let col = firstCol; col <= lastCol; ++col) {
const x = col / dpr;
ctx.lineTo(x, bezierY(p1, c1x, c1y, c2x, c2y, p2, x));
}
ctx.lineTo(p2.x, p2.y);
}
}
function bezierY(p1, c1x, c1y, c2x, c2y, p2, targetX) {
let tlo = 0;
let thi = 1;
for (let it = 0; it < 16; 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;
return u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y;
}
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];
}
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
};
}
function cutPoint(pts, targetX) {
return pointAtX(pts, targetX);
}
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(cutPoint(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");
if (!ctx)
return;
ctx.reset();
ctx.clearRect(0, 0, width, height);
const W = width;
const H = height;
if (W < 2 || H < 2)
return;
const padLeft = root.labelFontSize * 5;
const padRight = 0;
const padTop = 0;
const padBottom = root.labelFontSize + 14;
const plot = {
x: padLeft,
y: padTop,
w: W - padLeft - padRight,
h: H - padTop - padBottom
};
if (plot.w < 2 || plot.h < 2)
return;
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 value of root.values) {
const v = numeric(value);
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.map(v => numeric(v));
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 dpr = ZUtils.dprFor(canvas);
const devW = w => Math.max(1, Math.round(w * dpr));
const snapLine = (v, w) => {
const dw = devW(w);
return (Math.round(v * dpr - dw / 2) + dw / 2) / dpr;
};
const gridW = devW(1) / dpr;
ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha);
ctx.lineWidth = gridW;
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 = snapLine(xAt(i), 1);
ctx.beginPath();
ctx.moveTo(x, plot.y);
ctx.lineTo(x, H);
ctx.stroke();
}
}
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 = snapLine(yAt(v), 1);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(plot.x + plot.w, y);
ctx.stroke();
}
}
ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha);
ctx.font = root.labelFontSize + "pt '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 + 8);
}
}
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);
}
}
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: snapV(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) {
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;
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();
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 = ZUtils.snap(root.lineWidth, root);
strokeCurve(ctx, pts);
ctx.stroke();
}
}
if (root.markerX >= 0 && root.markerX <= root.xSteps) {
const x = markerPx;
ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6);
ctx.lineWidth = gridW;
const lineX = snapLine(x, 1);
let dashY = plot.y;
let dashOn = true;
while (dashY < H) {
const dashEnd = Math.min(dashY + 4, H);
if (dashOn) {
ctx.beginPath();
ctx.moveTo(lineX, snapV(dashY));
ctx.lineTo(lineX, snapV(dashEnd));
ctx.stroke();
}
dashY = dashEnd;
dashOn = !dashOn;
}
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.circle(snapV(x), y, root.markerDotRadius);
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()
}
@@ -101,6 +101,7 @@ orientation: Qt.Vertical
line1Color: card.accent
line1FillAlpha: 0.15
maxValue: 1.0
slideInterval: Config.dashboard.resourceUpdateInterval
slideProgress: 1
Connections {
@@ -114,9 +115,14 @@ orientation: Qt.Vertical
NumberAnimation {
id: slideAnim
// Only drives frames; the slide phase is the sample
// clock inside SparklineItem. It must not stop at the
// end of a cycle, or the newest value is held on the
// clipped edge until the next sample arrives.
duration: Config.dashboard.resourceUpdateInterval
easing.type: Easing.Linear
from: 0
loops: Animation.Infinite
property: "slideProgress"
target: sparkline
to: 1
+8 -12
View File
@@ -25,14 +25,6 @@ SnapLayout {
return ZUtils.snap(d.getHours() + d.getMinutes() / 60, root);
}
function formatHour(h) {
if (root.useTwelveHourClock) {
const hr = h % 12 === 0 ? 12 : h % 12;
return hr + (h < 12 ? " AM" : " PM");
}
return h;
}
Timer {
interval: 60000
repeat: true
@@ -78,8 +70,10 @@ SnapLayout {
minHorizontalLines: 3
yPaddingTop: horizontalLineStep
yPaddingBottom: horizontalLineStep
xLabel: i => root.formatHour(i)
yLabel: v => Math.round(v) + "°"
xLabelMode: LineGraph.Hour
useTwelveHourClock: root.useTwelveHourClock
yLabelMode: LineGraph.Value
yLabelSuffix: "°"
markerX: root.markerX
smoothing: root.smoothing
@@ -133,8 +127,10 @@ SnapLayout {
verticalLineStep: 3
lastXLine: false
horizontalLineStep: 30
xLabel: i => root.formatHour(i)
yLabel: v => v + "%"
xLabelMode: LineGraph.Hour
useTwelveHourClock: root.useTwelveHourClock
yLabelMode: LineGraph.Value
yLabelSuffix: "%"
markerX: root.markerX
smoothing: root.smoothing
+2 -7
View File
@@ -50,7 +50,6 @@ CustomRect {
SparklineItem {
id: sparkline
property bool initialized: false
property real smoothMax: targetMax * 1.6
property real targetMax: 1024
@@ -63,20 +62,15 @@ CustomRect {
line2Color: Colors.palette.m3tertiary
line2FillAlpha: 0.2
maxValue: smoothMax
slideInterval: Config.dashboard.resourceUpdateInterval
slideProgress: 1
Behavior on smoothMax {
enabled: sparkline.initialized
Anim {}
}
Component.onCompleted: {
sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024);
sparkline.smoothMax = Qt.binding(() => sparkline.targetMax * 1.6);
sparkline.initialized = true;
}
Connections {
@@ -94,6 +88,7 @@ CustomRect {
duration: Config.dashboard.resourceUpdateInterval
easing.type: Easing.Linear
from: 0
loops: Animation.Infinite
property: "slideProgress"
target: sparkline
to: 1
+6
View File
@@ -120,6 +120,12 @@ Item {
label: qsTr("Video memory")
icon: "memory_alt"
}
StateLayer {
onClicked: {
ZUtils.logPlacement(card, "card");
}
}
}
WrappedLoader {
+1 -1
View File
@@ -10,7 +10,7 @@ qml_module(ZShell-components
carouselview.hpp carouselview.cpp
sparklinecontent.hpp sparklinecontent.cpp
sparklineitem.hpp sparklineitem.cpp
sparklinesceneitem.hpp sparklinesceneitem.cpp
linegraph.hpp linegraph.cpp
roundedmask.hpp roundedmask.cpp
roundedclip.hpp roundedclip.cpp
snaplayout.hpp snaplayout.cpp
+910
View File
@@ -0,0 +1,910 @@
#include "linegraph.hpp"
#include "internal/linegeometry.hpp"
#include <QCanvasPainterItemRenderer>
#include <QFont>
#include <QQuickWindow>
#include <qcanvaspainter.h>
#include <algorithm>
#include <cmath>
#include <limits>
namespace ZShell::components {
namespace {
constexpr qreal kNaN = std::numeric_limits<qreal>::quiet_NaN();
bool sameReal(qreal a, qreal b) {
if (std::isnan(a) || std::isnan(b))
return std::isnan(a) && std::isnan(b);
return qFuzzyCompare(a, b);
}
// A value is plottable only if it is a real number. Everything else - null,
// undefined, NaN, an unparsable string from the API - is NaN, so the series splits
// there. Non-finite coordinates must never reach the path.
qreal parseValue(const QVariant& v) {
bool ok = false;
const double d = v.toDouble(&ok);
return ok && std::isfinite(d) ? qreal(d) : kNaN;
}
// Index of the segment whose right endpoint is at or past targetX.
int indexAt(const QVector<QPointF>& points, qreal targetX) {
for (int i = 0; i < points.size() - 1; ++i) {
if (targetX <= points[i + 1].x())
return i;
}
const int last = static_cast<int>(points.size()) - 2;
return last > 0 ? last : 0;
}
// The part of the polyline left/right of targetX, ending or beginning exactly at
// the curve point there. Used to carve a gap around the marker dot.
QVector<QPointF> cutLeft(
const QVector<QPointF>& points,
qreal targetX,
bool curveSmoothing) {
if (targetX <= points.first().x())
return {};
if (targetX >= points.last().x())
return points;
const int j = indexAt(points, targetX);
QVector<QPointF> left = points.first(j + 1);
if (targetX > points[j].x())
left.append(internal::pointAtX(points, targetX, curveSmoothing));
return left;
}
QVector<QPointF> cutRight(
const QVector<QPointF>& points,
qreal targetX,
bool curveSmoothing) {
if (targetX >= points.last().x())
return {};
if (targetX <= points.first().x())
return points;
const int j = indexAt(points, targetX);
QVector<QPointF> right = points.mid(j + 1);
if (targetX < points[j + 1].x())
right.prepend(internal::pointAtX(points, targetX, curveSmoothing));
return right;
}
QString formatXLabel(LineGraph::XLabelMode mode, int i, bool use12) {
if (mode == LineGraph::XLabelMode::None)
return {};
if (mode == LineGraph::XLabelMode::Number)
return QString::number(i);
// Hour: an hour of day 0..24, on a 12- or 24-hour clock.
if (use12) {
const int hr = (i % 12 == 0) ? 12 : (i % 12);
return QString::number(hr)
+ (i < 12 ? QStringLiteral(" AM")
: QStringLiteral(" PM"));
}
return QString::number(i);
}
QString formatYLabel(
LineGraph::YLabelMode mode,
qreal v,
int decimals,
const QString& suffix) {
if (mode == LineGraph::YLabelMode::Off)
return {};
return QString::number(v, 'f', qMax(0, decimals)) + suffix;
}
} // namespace
// Everything paint needs, computed once on the GUI thread in synchronizeData so
// paint() (render thread) only reads it. Curve vertices already carry their
// device-pixel column quantization from internal::linegeometry.
class LineGraphRenderer final : public QCanvasPainterItemRenderer {
public:
void synchronizeData(QCanvasPainterItem* item) override;
void paint(QCanvasPainter* painter) override;
private:
struct Plot {
qreal x = 0.0;
qreal y = 0.0;
qreal w = 0.0;
qreal h = 0.0;
};
struct Label {
QString text;
qreal x = 0.0;
qreal y = 0.0;
};
struct Segment {
QVector<QPointF> fill;
QVector<QVector<QPointF>> strokes;
};
struct Marker {
bool hasLine = false;
qreal lineX = 0.0;
QVector<QPair<qreal, qreal>> dash;
bool hasDot = false;
QPointF dot;
qreal dotRadius = 0.0;
};
struct Model {
bool valid = false;
qreal height = 0.0;
Plot plot;
qreal plotBottom = 0.0;
qreal gridWidth = 0.0;
qreal lineWidth = 0.0;
QVector<qreal> vGrid;
QVector<qreal> hGrid;
QVector<Label> xLabels;
QVector<Label> yLabels;
QVector<Segment> segments;
Marker marker;
qreal labelFontSize = 0.0;
};
void buildModel(const LineGraph* graph);
Model m_model;
QColor m_lineColor;
QColor m_gridColor;
QColor m_markerColor;
QColor m_labelColor;
qreal m_gridAlpha = 0.12;
qreal m_labelAlpha = 0.6;
qreal m_fillAlpha = 0.15;
qreal m_dpr = 1.0;
};
void LineGraphRenderer::synchronizeData(QCanvasPainterItem* item) {
auto* graph = static_cast<LineGraph*>(item);
m_lineColor = graph->m_lineColor;
m_gridColor = graph->m_gridColor;
m_markerColor = graph->m_markerColor;
m_labelColor = graph->m_labelColor;
m_gridAlpha = graph->m_gridAlpha;
m_labelAlpha = graph->m_labelAlpha;
m_fillAlpha = graph->m_fillAlpha;
m_dpr = graph->m_dpr > 0.0 ? graph->m_dpr : 1.0;
buildModel(graph);
}
void LineGraphRenderer::buildModel(const LineGraph* graph) {
Model model;
const qreal W = width();
const qreal H = height();
const qreal dpr = m_dpr;
model.height = H;
model.labelFontSize = graph->m_labelFontSize;
if (W < 2 || H < 2 || graph->m_xSteps < 1) {
m_model = model;
return;
}
const qreal padLeft = graph->m_labelFontSize * 5.0;
const qreal padBottom = graph->m_labelFontSize + 14.0;
model.plot = Plot {padLeft, 0.0, W - padLeft, H - padBottom};
if (model.plot.w < 2 || model.plot.h < 2) {
m_model = model;
return;
}
model.plotBottom = model.plot.y + model.plot.h;
// Raw series, holes as NaN.
QVector<qreal> raw;
raw.reserve(graph->m_values.size());
for (const QVariant& v : graph->m_values)
raw.append(parseValue(v));
// Moving average over the non-hole neighbours, the same window the sparkline
// applies at the same radius.
QVector<qreal> series = raw;
const int radius = qRound(graph->m_smoothing);
if (radius > 0 && !raw.isEmpty()) {
series.resize(raw.size());
for (int i = 0; i < raw.size(); ++i) {
if (std::isnan(raw[i])) {
series[i] = kNaN;
continue;
}
qreal sum = 0.0;
int n = 0;
for (int j = -radius; j <= radius; ++j) {
const int u = i + j;
if (u < 0 || u >= raw.size())
continue;
if (!std::isnan(raw[u])) {
sum += raw[u];
++n;
}
}
series[i] = n > 0 ? sum / qreal(n) : kNaN;
}
}
// y range: explicit yMin/yMax, or auto from the data snapped to the grid step.
qreal lo = graph->m_yMin;
qreal hi = graph->m_yMax;
const bool loAuto = std::isnan(lo);
const bool hiAuto = std::isnan(hi);
if (loAuto || hiAuto) {
qreal dmin = std::numeric_limits<qreal>::infinity();
qreal dmax = -std::numeric_limits<qreal>::infinity();
for (const qreal v : series) {
if (std::isnan(v))
continue;
dmin = std::min(dmin, v);
dmax = std::max(dmax, v);
}
if (std::isinf(dmin)) {
dmin = 0.0;
dmax = 1.0;
}
const int step = graph->m_horizontalLineStep;
const int minSteps = graph->m_minHorizontalLines > 1
? graph->m_minHorizontalLines - 1
: 0;
if (step > 0) {
if (hiAuto) {
const qreal baseLo =
loAuto ? std::floor(dmin / step) * step : lo;
const int span = std::max(
{static_cast<int>(std::ceil((dmax - baseLo) / step)),
minSteps,
1});
lo = baseLo;
hi = baseLo + qreal(span) * step;
} else {
const int span = std::max(
{static_cast<int>(std::ceil((hi - dmin) / step)),
minSteps,
1});
lo = hi - qreal(span) * step;
}
} else {
if (loAuto)
lo = dmin;
if (hiAuto)
hi = dmax;
if (hi <= lo)
hi = lo + 1.0;
}
}
const qreal scaleBottom = lo - graph->m_yPaddingBottom;
const qreal scaleTop = hi + graph->m_yPaddingTop;
const qreal range = scaleTop - scaleBottom;
if (range <= 0.0) {
m_model = model;
return;
}
const auto xAt = [&](qreal i) {
return model.plot.x + (i / qreal(graph->m_xSteps)) * model.plot.w;
};
const auto yAt = [&](qreal v) {
return model.plot.y
+ (1.0 - (v - scaleBottom) / range) * model.plot.h;
};
const auto devW = [&](qreal w) {
return std::max<qint64>(1, internal::toDev(w, dpr));
};
const qint64 gridWDev = devW(1.0);
const qint64 lineWDev = devW(graph->m_lineWidth);
model.gridWidth = internal::fromDev(gridWDev, dpr);
model.lineWidth = internal::fromDev(lineWDev, dpr);
const int vStep = graph->m_verticalLineStep;
const int hStep = graph->m_horizontalLineStep;
const auto tickVisible = [&](int i) {
if (i == 0 && !graph->m_firstXLine)
return false;
if (i == graph->m_xSteps && !graph->m_lastXLine)
return false;
return true;
};
if (vStep > 0) {
for (int i = 0; i <= graph->m_xSteps; i += vStep) {
if (!tickVisible(i))
continue;
model.vGrid.append(
internal::snapLine(xAt(i), gridWDev, dpr));
}
}
const qreal vStart = lo
- (graph->m_yPaddingBottom > 0.0
? std::floor(graph->m_yPaddingBottom / hStep)
* hStep
: 0.0);
if (hStep > 0) {
for (qreal v = vStart; v <= hi + 0.001; v += hStep)
model.hGrid.append(
internal::snapLine(yAt(v), gridWDev, dpr));
}
if (vStep > 0 && graph->m_xLabelMode != LineGraph::XLabelMode::None) {
for (int i = 0; i <= graph->m_xSteps; i += vStep) {
if (!tickVisible(i))
continue;
const QString text = formatXLabel(
graph->m_xLabelMode, i, graph->m_useTwelveHourClock);
if (!text.isEmpty())
model.xLabels.append(Label {
text, xAt(i) + 4.0,
model.plot.y + model.plot.h + 8.0});
}
}
if (hStep > 0 && graph->m_yLabelMode != LineGraph::YLabelMode::Off) {
for (qreal v = vStart; v <= hi + 0.001; v += hStep) {
const QString text = formatYLabel(
graph->m_yLabelMode, v, graph->m_yLabelDecimals,
graph->m_yLabelSuffix);
if (!text.isEmpty())
model.yLabels.append(Label {text, 2.0, yAt(v) - 2.0});
}
}
// Split the series into contiguous runs; each run becomes one curve. Points
// land on device-pixel columns so a flat run stays on whole columns.
const auto snapX = [&](qreal logical) {
return internal::fromDev(internal::toDev(logical, dpr), dpr);
};
QVector<QVector<QPointF>> runs;
QVector<QPointF> current;
for (int i = 0; i < series.size(); ++i) {
if (std::isnan(series[i])) {
if (!current.isEmpty()) {
runs.append(current);
current.clear();
}
continue;
}
current.append(QPointF(snapX(xAt(i)), yAt(series[i])));
}
if (!current.isEmpty())
runs.append(current);
const bool curve = graph->m_curveSmoothing;
const bool hasMarker =
graph->m_markerX >= 0.0 && graph->m_markerX <= graph->m_xSteps;
qreal markerPx = 0.0;
qreal gapPx = 0.0;
if (hasMarker) {
markerPx = xAt(graph->m_markerX);
qreal slope = 0.0;
for (const auto& run : runs) {
if (run.size() > 2 && run.first().x() <= markerPx
&& markerPx <= run.last().x()
&& markerPx - 1.0 >= run.first().x()
&& markerPx + 1.0 <= run.last().x()) {
slope = (internal::pointAtX(run, markerPx + 1.0, curve).y()
- internal::pointAtX(run, markerPx - 1.0, curve)
.y())
/ 2.0;
break;
}
}
gapPx = (graph->m_markerDotRadius + graph->m_markerGap)
/ std::sqrt(1.0 + slope * slope);
}
for (const auto& run : runs) {
if (run.size() < 2)
continue;
Segment seg;
seg.fill = internal::curveVertices(run, dpr, curve);
seg.fill.append(QPointF(run.last().x(), model.plotBottom));
seg.fill.append(QPointF(run.first().x(), model.plotBottom));
if (hasMarker) {
const QVector<QPointF> left =
cutLeft(run, markerPx - gapPx, curve);
const QVector<QPointF> right =
cutRight(run, markerPx + gapPx, curve);
if (left.size() >= 2)
seg.strokes.append(
internal::curveVertices(left, dpr, curve));
if (right.size() >= 2)
seg.strokes.append(
internal::curveVertices(right, dpr, curve));
} else {
seg.strokes.append(internal::curveVertices(run, dpr, curve));
}
model.segments.append(seg);
}
if (hasMarker) {
model.marker.hasLine = true;
model.marker.lineX =
internal::snapLine(markerPx, gridWDev, dpr);
// Dashes are drawn as solid segments: QCanvasPainter strokes are always
// solid, so a dashed line is a run of short strokes, as LineGraph.qml did.
qreal dashY = model.plot.y;
bool on = true;
while (dashY < H) {
const qreal dashEnd = std::min(dashY + 4.0, H);
if (on)
model.marker.dash.append(
{snapX(dashY), snapX(dashEnd)});
dashY = dashEnd;
on = !on;
}
const int i0 = static_cast<int>(std::floor(graph->m_markerX));
const qreal v0 = (i0 >= 0 && i0 < series.size()) ? series[i0]
: kNaN;
const qreal v1 = (i0 + 1 >= 0 && i0 + 1 < series.size())
? series[i0 + 1]
: kNaN;
qreal y = kNaN;
if (!std::isnan(v0) && !std::isnan(v1))
y = yAt(v0 + (v1 - v0) * (graph->m_markerX - qreal(i0)));
else if (!std::isnan(v0))
y = yAt(v0);
if (!std::isnan(y)) {
model.marker.hasDot = true;
model.marker.dot = QPointF(snapX(markerPx), y);
model.marker.dotRadius = graph->m_markerDotRadius;
}
}
// The plot is well-formed: grid, labels and marker can draw even with no curve
// segments, matching the QML which painted the grid regardless of data.
model.valid = true;
m_model = model;
}
void LineGraphRenderer::paint(QCanvasPainter* painter) {
painter->clearRect(0, 0, width(), height());
if (!m_model.valid)
return;
const Model& m = m_model;
// Grid: every line in one path, one stroke.
QColor grid = m_gridColor;
grid.setAlphaF(static_cast<float>(m_gridAlpha));
painter->setStrokeStyle(grid);
painter->setLineWidth(m.gridWidth);
painter->beginPath();
for (const qreal x : m.vGrid) {
painter->moveTo(x, m.plot.y);
painter->lineTo(x, m.height);
}
for (const qreal y : m.hGrid) {
painter->moveTo(0, y);
painter->lineTo(m.plot.x + m.plot.w, y);
}
painter->stroke();
// Axis labels.
QColor label = m_labelColor;
label.setAlphaF(static_cast<float>(m_labelAlpha));
painter->setFillStyle(label);
QFont font(QStringLiteral("sans-serif"));
font.setPointSizeF(m.labelFontSize);
painter->setFont(font);
painter->setTextAlign(QCanvasPainter::TextAlign::Left);
painter->setTextBaseline(QCanvasPainter::TextBaseline::Top);
for (const Label& l : m.xLabels)
painter->fillText(l.text, l.x, l.y);
painter->setTextBaseline(QCanvasPainter::TextBaseline::Bottom);
for (const Label& l : m.yLabels)
painter->fillText(l.text, l.x, l.y);
// Area fill, then the stroked curve pieces on top.
QColor fill = m_lineColor;
fill.setAlphaF(static_cast<float>(m_fillAlpha));
painter->setFillStyle(fill);
for (const Segment& seg : m.segments) {
painter->beginPath();
painter->moveTo(seg.fill.first());
for (int i = 1; i < seg.fill.size(); ++i)
painter->lineTo(seg.fill[i]);
painter->fill();
}
painter->setStrokeStyle(m_lineColor);
painter->setLineWidth(m.lineWidth);
painter->setLineCap(QCanvasPainter::LineCap::Round);
painter->setLineJoin(QCanvasPainter::LineJoin::Round);
for (const Segment& seg : m.segments) {
for (const auto& poly : seg.strokes) {
painter->beginPath();
painter->moveTo(poly.first());
for (int i = 1; i < poly.size(); ++i)
painter->lineTo(poly[i]);
painter->stroke();
}
}
if (m.marker.hasLine) {
QColor markerLine = m_markerColor;
markerLine.setAlphaF(0.6f);
painter->setStrokeStyle(markerLine);
painter->setLineWidth(m.gridWidth);
painter->beginPath();
for (const auto& d : m.marker.dash) {
painter->moveTo(m.marker.lineX, d.first);
painter->lineTo(m.marker.lineX, d.second);
}
painter->stroke();
}
if (m.marker.hasDot) {
painter->setFillStyle(m_markerColor);
painter->beginPath();
painter->circle(
m.marker.dot.x(), m.marker.dot.y(), m.marker.dotRadius);
painter->fill();
}
}
LineGraph::LineGraph(QQuickItem* parent) : QCanvasPainterItem(parent) {
setFillColor(Qt::transparent);
setAlphaBlending(true);
}
QCanvasPainterItemRenderer* LineGraph::createItemRenderer() const {
return new LineGraphRenderer;
}
void LineGraph::itemChange(ItemChange change, const ItemChangeData& value) {
QCanvasPainterItem::itemChange(change, value);
if (change == ItemSceneChange && value.window)
m_dpr = value.window->effectiveDevicePixelRatio();
else if (change == ItemDevicePixelRatioHasChanged && window())
m_dpr = window()->effectiveDevicePixelRatio();
}
QVariantList LineGraph::values() const {
return m_values;
}
void LineGraph::setValues(const QVariantList& values) {
if (m_values == values) return;
m_values = values;
emit valuesChanged();
update();
}
int LineGraph::xSteps() const {
return m_xSteps;
}
void LineGraph::setXSteps(int xSteps) {
if (m_xSteps == xSteps) return;
m_xSteps = xSteps;
emit xStepsChanged();
update();
}
qreal LineGraph::yMin() const {
return m_yMin;
}
void LineGraph::setYMin(qreal value) {
if (sameReal(m_yMin, value)) return;
m_yMin = value;
emit yMinChanged();
update();
}
qreal LineGraph::yMax() const {
return m_yMax;
}
void LineGraph::setYMax(qreal value) {
if (sameReal(m_yMax, value)) return;
m_yMax = value;
emit yMaxChanged();
update();
}
qreal LineGraph::yPaddingTop() const {
return m_yPaddingTop;
}
void LineGraph::setYPaddingTop(qreal value) {
if (sameReal(m_yPaddingTop, value)) return;
m_yPaddingTop = value;
emit yPaddingTopChanged();
update();
}
qreal LineGraph::yPaddingBottom() const {
return m_yPaddingBottom;
}
void LineGraph::setYPaddingBottom(qreal value) {
if (sameReal(m_yPaddingBottom, value)) return;
m_yPaddingBottom = value;
emit yPaddingBottomChanged();
update();
}
int LineGraph::verticalLineStep() const {
return m_verticalLineStep;
}
void LineGraph::setVerticalLineStep(int step) {
if (m_verticalLineStep == step) return;
m_verticalLineStep = step;
emit verticalLineStepChanged();
update();
}
int LineGraph::horizontalLineStep() const {
return m_horizontalLineStep;
}
void LineGraph::setHorizontalLineStep(int step) {
if (m_horizontalLineStep == step) return;
m_horizontalLineStep = step;
emit horizontalLineStepChanged();
update();
}
bool LineGraph::firstXLine() const {
return m_firstXLine;
}
void LineGraph::setFirstXLine(bool show) {
if (m_firstXLine == show) return;
m_firstXLine = show;
emit firstXLineChanged();
update();
}
bool LineGraph::lastXLine() const {
return m_lastXLine;
}
void LineGraph::setLastXLine(bool show) {
if (m_lastXLine == show) return;
m_lastXLine = show;
emit lastXLineChanged();
update();
}
int LineGraph::minHorizontalLines() const {
return m_minHorizontalLines;
}
void LineGraph::setMinHorizontalLines(int count) {
if (m_minHorizontalLines == count) return;
m_minHorizontalLines = count;
emit minHorizontalLinesChanged();
update();
}
LineGraph::XLabelMode LineGraph::xLabelMode() const {
return m_xLabelMode;
}
void LineGraph::setXLabelMode(XLabelMode mode) {
if (m_xLabelMode == mode) return;
m_xLabelMode = mode;
emit xLabelModeChanged();
update();
}
bool LineGraph::useTwelveHourClock() const {
return m_useTwelveHourClock;
}
void LineGraph::setUseTwelveHourClock(bool use) {
if (m_useTwelveHourClock == use) return;
m_useTwelveHourClock = use;
emit useTwelveHourClockChanged();
update();
}
LineGraph::YLabelMode LineGraph::yLabelMode() const {
return m_yLabelMode;
}
void LineGraph::setYLabelMode(YLabelMode mode) {
if (m_yLabelMode == mode) return;
m_yLabelMode = mode;
emit yLabelModeChanged();
update();
}
QString LineGraph::yLabelSuffix() const {
return m_yLabelSuffix;
}
void LineGraph::setYLabelSuffix(const QString& suffix) {
if (m_yLabelSuffix == suffix) return;
m_yLabelSuffix = suffix;
emit yLabelSuffixChanged();
update();
}
int LineGraph::yLabelDecimals() const {
return m_yLabelDecimals;
}
void LineGraph::setYLabelDecimals(int decimals) {
if (m_yLabelDecimals == decimals) return;
m_yLabelDecimals = decimals;
emit yLabelDecimalsChanged();
update();
}
qreal LineGraph::smoothing() const {
return m_smoothing;
}
void LineGraph::setSmoothing(qreal smoothing) {
smoothing = qMax<qreal>(0.0, smoothing);
if (sameReal(m_smoothing, smoothing)) return;
m_smoothing = smoothing;
emit smoothingChanged();
update();
}
bool LineGraph::curveSmoothing() const {
return m_curveSmoothing;
}
void LineGraph::setCurveSmoothing(bool enabled) {
if (m_curveSmoothing == enabled) return;
m_curveSmoothing = enabled;
emit curveSmoothingChanged();
update();
}
qreal LineGraph::markerX() const {
return m_markerX;
}
void LineGraph::setMarkerX(qreal markerX) {
if (sameReal(m_markerX, markerX)) return;
m_markerX = markerX;
emit markerXChanged();
update();
}
qreal LineGraph::markerGap() const {
return m_markerGap;
}
void LineGraph::setMarkerGap(qreal gap) {
if (sameReal(m_markerGap, gap)) return;
m_markerGap = gap;
emit markerGapChanged();
update();
}
QColor LineGraph::lineColor() const {
return m_lineColor;
}
void LineGraph::setLineColor(const QColor& color) {
if (m_lineColor == color) return;
m_lineColor = color;
emit lineColorChanged();
update();
}
QColor LineGraph::gridColor() const {
return m_gridColor;
}
void LineGraph::setGridColor(const QColor& color) {
if (m_gridColor == color) return;
m_gridColor = color;
emit gridColorChanged();
update();
}
QColor LineGraph::markerColor() const {
return m_markerColor;
}
void LineGraph::setMarkerColor(const QColor& color) {
if (m_markerColor == color) return;
m_markerColor = color;
emit markerColorChanged();
update();
}
QColor LineGraph::labelColor() const {
return m_labelColor;
}
void LineGraph::setLabelColor(const QColor& color) {
if (m_labelColor == color) return;
m_labelColor = color;
emit labelColorChanged();
update();
}
qreal LineGraph::gridAlpha() const {
return m_gridAlpha;
}
void LineGraph::setGridAlpha(qreal alpha) {
if (sameReal(m_gridAlpha, alpha)) return;
m_gridAlpha = alpha;
emit gridAlphaChanged();
update();
}
qreal LineGraph::labelAlpha() const {
return m_labelAlpha;
}
void LineGraph::setLabelAlpha(qreal alpha) {
if (sameReal(m_labelAlpha, alpha)) return;
m_labelAlpha = alpha;
emit labelAlphaChanged();
update();
}
qreal LineGraph::fillAlpha() const {
return m_fillAlpha;
}
void LineGraph::setFillAlpha(qreal alpha) {
if (sameReal(m_fillAlpha, alpha)) return;
m_fillAlpha = alpha;
emit fillAlphaChanged();
update();
}
qreal LineGraph::lineWidth() const {
return m_lineWidth;
}
void LineGraph::setLineWidth(qreal width) {
if (sameReal(m_lineWidth, width)) return;
m_lineWidth = width;
emit lineWidthChanged();
update();
}
qreal LineGraph::markerDotRadius() const {
return m_markerDotRadius;
}
void LineGraph::setMarkerDotRadius(qreal radius) {
if (sameReal(m_markerDotRadius, radius)) return;
m_markerDotRadius = radius;
emit markerDotRadiusChanged();
update();
}
qreal LineGraph::labelFontSize() const {
return m_labelFontSize;
}
void LineGraph::setLabelFontSize(qreal size) {
if (sameReal(m_labelFontSize, size)) return;
m_labelFontSize = size;
emit labelFontSizeChanged();
update();
}
} // namespace ZShell::components
+290
View File
@@ -0,0 +1,290 @@
#pragma once
#include <QCanvasPainterItem>
#include <QColor>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qtmetamacros.h>
#include <qvariant.h>
#include <QString>
#include <limits>
namespace ZShell::components {
class LineGraphRenderer;
// A line graph with an optional grid, axis labels, y auto-scaling, a moving
// average, smoothed Catmull-Rom curve, an area fill and a marker. Static: it draws
// whatever `values` holds at the given x steps, unlike Sparkline which slides a
// rolling buffer. Both share curve smoothing and device-pixel alignment through
// internal::linegeometry.
class LineGraph : public QCanvasPainterItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
QVariantList values READ values WRITE setValues NOTIFY valuesChanged
FINAL)
Q_PROPERTY(int xSteps READ xSteps WRITE setXSteps NOTIFY xStepsChanged FINAL)
Q_PROPERTY(qreal yMin READ yMin WRITE setYMin NOTIFY yMinChanged FINAL)
Q_PROPERTY(qreal yMax READ yMax WRITE setYMax NOTIFY yMaxChanged FINAL)
Q_PROPERTY(
qreal yPaddingTop READ yPaddingTop WRITE setYPaddingTop NOTIFY
yPaddingTopChanged FINAL)
Q_PROPERTY(
qreal yPaddingBottom READ yPaddingBottom WRITE setYPaddingBottom NOTIFY
yPaddingBottomChanged FINAL)
Q_PROPERTY(
int verticalLineStep READ verticalLineStep WRITE setVerticalLineStep
NOTIFY verticalLineStepChanged FINAL)
Q_PROPERTY(
int horizontalLineStep READ horizontalLineStep WRITE
setHorizontalLineStep NOTIFY horizontalLineStepChanged FINAL)
Q_PROPERTY(
bool firstXLine READ firstXLine WRITE setFirstXLine NOTIFY
firstXLineChanged FINAL)
Q_PROPERTY(
bool lastXLine READ lastXLine WRITE setLastXLine NOTIFY
lastXLineChanged FINAL)
Q_PROPERTY(
int minHorizontalLines READ minHorizontalLines WRITE
setMinHorizontalLines NOTIFY minHorizontalLinesChanged FINAL)
Q_PROPERTY(
XLabelMode xLabelMode READ xLabelMode WRITE setXLabelMode NOTIFY
xLabelModeChanged FINAL)
Q_PROPERTY(
bool useTwelveHourClock READ useTwelveHourClock WRITE
setUseTwelveHourClock NOTIFY useTwelveHourClockChanged FINAL)
Q_PROPERTY(
YLabelMode yLabelMode READ yLabelMode WRITE setYLabelMode NOTIFY
yLabelModeChanged FINAL)
Q_PROPERTY(
QString yLabelSuffix READ yLabelSuffix WRITE setYLabelSuffix NOTIFY
yLabelSuffixChanged FINAL)
Q_PROPERTY(
int yLabelDecimals READ yLabelDecimals WRITE setYLabelDecimals NOTIFY
yLabelDecimalsChanged FINAL)
Q_PROPERTY(
qreal smoothing READ smoothing WRITE setSmoothing NOTIFY
smoothingChanged FINAL)
Q_PROPERTY(
bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY
curveSmoothingChanged FINAL)
Q_PROPERTY(
qreal markerX READ markerX WRITE setMarkerX NOTIFY markerXChanged FINAL)
Q_PROPERTY(
qreal markerGap READ markerGap WRITE setMarkerGap NOTIFY
markerGapChanged FINAL)
Q_PROPERTY(
QColor lineColor READ lineColor WRITE setLineColor NOTIFY
lineColorChanged FINAL)
Q_PROPERTY(
QColor gridColor READ gridColor WRITE setGridColor NOTIFY
gridColorChanged FINAL)
Q_PROPERTY(
QColor markerColor READ markerColor WRITE setMarkerColor NOTIFY
markerColorChanged FINAL)
Q_PROPERTY(
QColor labelColor READ labelColor WRITE setLabelColor NOTIFY
labelColorChanged FINAL)
Q_PROPERTY(
qreal gridAlpha READ gridAlpha WRITE setGridAlpha NOTIFY
gridAlphaChanged FINAL)
Q_PROPERTY(
qreal labelAlpha READ labelAlpha WRITE setLabelAlpha NOTIFY
labelAlphaChanged FINAL)
Q_PROPERTY(
qreal fillAlpha READ fillAlpha WRITE setFillAlpha NOTIFY
fillAlphaChanged FINAL)
Q_PROPERTY(
qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY
lineWidthChanged FINAL)
Q_PROPERTY(
qreal markerDotRadius READ markerDotRadius WRITE setMarkerDotRadius
NOTIFY markerDotRadiusChanged FINAL)
Q_PROPERTY(
qreal labelFontSize READ labelFontSize WRITE setLabelFontSize NOTIFY
labelFontSizeChanged FINAL)
public:
// How an x tick (integer index 0..xSteps) is labelled. Hour renders an
// hour-of-day, honouring useTwelveHourClock.
enum class XLabelMode { None, Number, Hour };
Q_ENUM(XLabelMode)
// How a y tick value is labelled. Value renders the number rounded to
// yLabelDecimals places, followed by yLabelSuffix.
enum class YLabelMode { Off, Value };
Q_ENUM(YLabelMode)
explicit LineGraph(QQuickItem* parent = nullptr);
[[nodiscard]] QVariantList values() const;
void setValues(const QVariantList& values);
[[nodiscard]] int xSteps() const;
void setXSteps(int xSteps);
[[nodiscard]] qreal yMin() const;
void setYMin(qreal value);
[[nodiscard]] qreal yMax() const;
void setYMax(qreal value);
[[nodiscard]] qreal yPaddingTop() const;
void setYPaddingTop(qreal value);
[[nodiscard]] qreal yPaddingBottom() const;
void setYPaddingBottom(qreal value);
[[nodiscard]] int verticalLineStep() const;
void setVerticalLineStep(int step);
[[nodiscard]] int horizontalLineStep() const;
void setHorizontalLineStep(int step);
[[nodiscard]] bool firstXLine() const;
void setFirstXLine(bool show);
[[nodiscard]] bool lastXLine() const;
void setLastXLine(bool show);
[[nodiscard]] int minHorizontalLines() const;
void setMinHorizontalLines(int count);
[[nodiscard]] XLabelMode xLabelMode() const;
void setXLabelMode(XLabelMode mode);
[[nodiscard]] bool useTwelveHourClock() const;
void setUseTwelveHourClock(bool use);
[[nodiscard]] YLabelMode yLabelMode() const;
void setYLabelMode(YLabelMode mode);
[[nodiscard]] QString yLabelSuffix() const;
void setYLabelSuffix(const QString& suffix);
[[nodiscard]] int yLabelDecimals() const;
void setYLabelDecimals(int decimals);
[[nodiscard]] qreal smoothing() const;
void setSmoothing(qreal smoothing);
[[nodiscard]] bool curveSmoothing() const;
void setCurveSmoothing(bool enabled);
[[nodiscard]] qreal markerX() const;
void setMarkerX(qreal markerX);
[[nodiscard]] qreal markerGap() const;
void setMarkerGap(qreal gap);
[[nodiscard]] QColor lineColor() const;
void setLineColor(const QColor& color);
[[nodiscard]] QColor gridColor() const;
void setGridColor(const QColor& color);
[[nodiscard]] QColor markerColor() const;
void setMarkerColor(const QColor& color);
[[nodiscard]] QColor labelColor() const;
void setLabelColor(const QColor& color);
[[nodiscard]] qreal gridAlpha() const;
void setGridAlpha(qreal alpha);
[[nodiscard]] qreal labelAlpha() const;
void setLabelAlpha(qreal alpha);
[[nodiscard]] qreal fillAlpha() const;
void setFillAlpha(qreal alpha);
[[nodiscard]] qreal lineWidth() const;
void setLineWidth(qreal width);
[[nodiscard]] qreal markerDotRadius() const;
void setMarkerDotRadius(qreal radius);
[[nodiscard]] qreal labelFontSize() const;
void setLabelFontSize(qreal size);
signals:
void valuesChanged();
void xStepsChanged();
void yMinChanged();
void yMaxChanged();
void yPaddingTopChanged();
void yPaddingBottomChanged();
void verticalLineStepChanged();
void horizontalLineStepChanged();
void firstXLineChanged();
void lastXLineChanged();
void minHorizontalLinesChanged();
void xLabelModeChanged();
void useTwelveHourClockChanged();
void yLabelModeChanged();
void yLabelSuffixChanged();
void yLabelDecimalsChanged();
void smoothingChanged();
void curveSmoothingChanged();
void markerXChanged();
void markerGapChanged();
void lineColorChanged();
void gridColorChanged();
void markerColorChanged();
void labelColorChanged();
void gridAlphaChanged();
void labelAlphaChanged();
void fillAlphaChanged();
void lineWidthChanged();
void markerDotRadiusChanged();
void labelFontSizeChanged();
protected:
[[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer()
const override;
// Device-pixel snapping and stroke width are quantized in device pixels, so
// they must use the ratio the window actually has.
void itemChange(ItemChange change, const ItemChangeData& value) override;
private:
friend class LineGraphRenderer;
QVariantList m_values;
int m_xSteps = 24;
qreal m_yMin = std::numeric_limits<qreal>::quiet_NaN();
qreal m_yMax = std::numeric_limits<qreal>::quiet_NaN();
qreal m_yPaddingTop = 0.0;
qreal m_yPaddingBottom = 0.0;
int m_verticalLineStep = 0;
int m_horizontalLineStep = 0;
bool m_firstXLine = true;
bool m_lastXLine = false;
int m_minHorizontalLines = 0;
XLabelMode m_xLabelMode = XLabelMode::None;
bool m_useTwelveHourClock = false;
YLabelMode m_yLabelMode = YLabelMode::Off;
QString m_yLabelSuffix;
int m_yLabelDecimals = 0;
qreal m_smoothing = 0.0;
bool m_curveSmoothing = true;
qreal m_markerX = -1.0;
qreal m_markerGap = 2.0;
QColor m_lineColor = Qt::white;
QColor m_gridColor = Qt::white;
QColor m_markerColor = Qt::white;
QColor m_labelColor = Qt::white;
qreal m_gridAlpha = 0.12;
qreal m_labelAlpha = 0.6;
qreal m_fillAlpha = 0.15;
qreal m_lineWidth = 2.0;
qreal m_markerDotRadius = 4.0;
qreal m_labelFontSize = 8.0;
qreal m_dpr = 1.0;
};
} // namespace ZShell::components
+113 -113
View File
@@ -1,5 +1,7 @@
#include "sparklinecontent.hpp"
#include "internal/linegeometry.hpp"
#include <qmath.h>
#include <qpainter.h>
#include <qpen.h>
@@ -10,85 +12,47 @@
namespace ZShell::components {
using internal::fromDev;
using internal::snapLine;
using internal::toDev;
// Device-pixel snapping, bezier sampling and Catmull-Rom controls come from
// internal::linegeometry, shared with LineGraph so both graphs align and smooth
// their lines identically.
namespace {
qint64 toDev(qreal logical, qreal dpr) {
return std::llround(logical * dpr);
}
qreal fromDev(qint64 device, qreal dpr) {
return qreal(device) / dpr;
}
// A line center whose device position leaves a whole-pixel-wide stroke: an even
// width sits on pixel boundaries, an odd width on pixel centers. Same rule as
// snapLine() in Components/LineGraph.qml.
qreal snapLine(qreal logical, qint64 widthDev, qreal dpr) {
const qreal half = qreal(widthDev) / 2.0;
return (static_cast<qreal>(std::llround(logical * dpr - half)) + half) /
dpr;
}
// y of a cubic bezier at targetX, found by bisection on the parameter. Used to
// place one vertex per device pixel column, so a segment never interpolates its
// edge across several columns.
qreal cubicY(
const QPointF& p1,
const QPointF& c1,
const QPointF& c2,
const QPointF& p2,
qreal targetX) {
qreal lo = 0.0;
qreal hi = 1.0;
for (int it = 0; it < 16; ++it) {
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
const qreal x = u * u * u * p1.x() + 3 * u * u * t * c1.x() +
3 * u * t * t * c2.x() + t * t * t * p2.x();
if (x < targetX)
lo = t;
else
hi = t;
// A QQuickPaintedItem only lands on the device grid when its logical size is a
// whole number *and* that whole number times dpr is a whole number as well.
// Qt derives the painter node from qRound(size) (qquickpainteditem.cpp), makes
// the texture from that rounded size times dpr, scales the painter by
// texture/size rather than by dpr, and draws the texture into a quad of the
// rounded size (qsgdefaultpainternode.cpp). Any fraction therefore moves the
// painter scale off dpr - a height of 75.3333 at dpr 1.5 becomes a node of 75
// and a texture of qRound(112.5) = 113, a scale of 1.5067, so every y snapped
// to the dpr 1.5 grid falls between texture rows and the whole texture is then
// resampled to a 112.5 pixel tall quad. That is the fuzz. 74 -> 111 and
// 76 -> 114 are both exact.
//
// `grow` picks the direction: shrink to stay inside the slot, grow to stay
// outside it. The overhang on the right has to grow, or the texture's own edge
// moves inside the parent's clip and slides left and right with the scroll.
qreal snapPaintedExtent(qreal logical, qreal dpr, bool grow) {
qreal whole = grow ? std::ceil(logical - 1e-6) : std::floor(logical + 1e-6);
for (int guard = 0; guard < 16 && whole >= 1.0; ++guard) {
const qreal device = whole * dpr;
if (std::abs(device - std::round(device)) < 1e-6)
return whole;
whole += grow ? 1.0 : -1.0;
}
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
return u * u * u * p1.y() + 3 * u * u * t * c1.y() +
3 * u * t * t * c2.y() + t * t * t * p2.y();
}
// Catmull-Rom controls with the control y clamped to the segment's own y span,
// so a curve never overshoots past its endpoints.
void segmentControls(
const QVector<QPointF>& points,
int i,
bool curveSmoothing,
QPointF& c1,
QPointF& c2) {
const QPointF& p0 = points[qMax(i - 1, 0)];
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
const QPointF& p3 = points[qMin(i + 2, points.size() - 1)];
if (curveSmoothing && points.size() > 2) {
const qreal k = 1.0 / 6.0;
c1 = p1 + (p2 - p0) * k;
c2 = p2 - (p3 - p1) * k;
const qreal minY = qMin(p1.y(), p2.y());
const qreal maxY = qMax(p1.y(), p2.y());
c1.setY(qBound(minY, c1.y(), maxY));
c2.setY(qBound(minY, c2.y(), maxY));
return;
}
const qreal cx = (p1.x() + p2.x()) * 0.5;
c1 = QPointF(cx, p1.y());
c2 = QPointF(cx, p2.y());
// No whole extent within the guard window (an exotic dpr): a whole number is
// still closer than a fractional one, which Qt rounds away anyway.
return std::max(1.0, grow ? whole : std::floor(logical));
}
} // namespace
SparklineContent::SparklineContent(QQuickItem* parent)
: QQuickPaintedItem(parent) {
m_sampleClock.start();
setAntialiasing(true);
}
@@ -111,12 +75,15 @@ qreal SparklineContent::stepX() const {
2,
(m_line1 && m_line1->count() >= 2) ? m_line1->count()
: (m_line2 ? m_line2->count() : 2));
const qreal logical =
m_sliding ? m_viewportW / static_cast<qreal>(m_historyLength - 1)
: m_viewportW / static_cast<qreal>(len - 1);
// A whole device pixel per step: every sample lands on a pixel column, and
// one slide step moves the content by an exact pixel count.
return fromDev((std::max)(qint64(1), toDev(logical, m_dpr)), m_dpr);
// Exactly the slot divided by the history, in logical pixels. Rounding this
// to a whole device pixel was tempting - it puts every sample on a pixel
// column - but the rounding error accumulates over the whole window: history
// - 1 steps then no longer span the slot, so the oldest value either leaves
// the clip before its tick (rounding up) or sits at the edge until the buffer
// evicts it, popping out at the tick (rounding down). crispness does not need
// it: internal::curveVertices puts a vertex on every device column anyway.
return m_sliding ? m_viewportW / static_cast<qreal>(m_historyLength - 1)
: m_viewportW / static_cast<qreal>(len - 1);
}
void SparklineContent::setViewportSize(
@@ -129,6 +96,30 @@ void SparklineContent::setViewportSize(
invalidate();
}
void SparklineContent::setSlideInterval(qreal intervalMs) {
if (qFuzzyCompare(m_slideIntervalMs, intervalMs)) return;
m_slideIntervalMs = intervalMs;
m_sampleClock.restart();
invalidate();
}
qreal SparklineContent::slidePhase(qreal progress) const {
if (m_slideIntervalMs <= 0.0) return qBound(0.0, progress, 1.0);
// The sample clock, not the animation's timeline. The NumberAnimation runs on
// the render-loop clock while the sample arrives from a QTimer of the same
// duration, so it reaches 1 on the last frame before that timer fires and then
// stops. For that frame the newest value is parked exactly on the parent's
// clip edge - it shows up at the clipped edge instead of sliding in from
// beyond it, and the following frame travels nothing. Holding is capped at one
// step, so a stalled producer cannot scroll the curve into empty space.
const qreal elapsed = static_cast<qreal>(m_sampleClock.elapsed());
return qBound(0.0, elapsed / m_slideIntervalMs, 1.0);
}
void SparklineContent::setSlideProgress(qreal progress) {
setX(-stepX() * slidePhase(progress));
}
void SparklineContent::setLine1(internal::CircularBuffer* buffer) {
if (m_line1 == buffer) return;
if (m_line1) disconnect(m_line1, nullptr, this, nullptr);
@@ -145,13 +136,24 @@ void SparklineContent::setLine2(internal::CircularBuffer* buffer) {
invalidate();
}
void SparklineContent::onSampleArrived() {
m_sampleClock.restart();
invalidate();
// The offset reset and the buffer shifting its values by one step are a
// single change: applied here, both are pending before the next sync, so they
// cannot land in different frames. Left to the animation to write, a frame
// can paint the reindexed values with the previous cycle's full offset still
// in place, which moves the whole graph one extra step left for that frame.
setSlideProgress(0.0);
}
void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
if (!buffer) return;
connect(
buffer,
&internal::CircularBuffer::valuesChanged,
this,
&SparklineContent::invalidate);
&SparklineContent::onSampleArrived);
connect(buffer, &QObject::destroyed, this, [this, buffer]() {
if (m_line1 == buffer) m_line1 = nullptr;
if (m_line2 == buffer) m_line2 = nullptr;
@@ -220,8 +222,20 @@ void SparklineContent::invalidate() {
}
void SparklineContent::applyGeometry() {
const qreal pad = m_sliding ? stepX() : 0.0;
setSize(QSizeF(m_viewportW + pad, m_viewportH));
const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0;
// The newest value is drawn one step past the slot's right edge, so the child
// has to overhang the slot and the overhang is offset left as it slides. Two
// steps, not one: the newest vertex sits at one step past the edge and its
// stroke cap extends beyond that, so a one-step overhang puts the texture's
// own right edge exactly on the clip boundary at the end of a cycle, where the
// cut-off ink becomes visible for a frame.
const qreal pad = m_sliding ? 2.0 * stepX() : 0.0;
// Qt rounds the painted size anyway, so choose a whole value that keeps
// device alignment. Width grows, to keep that overhang outside the clip;
// height shrinks, to stay inside the slot.
const qreal paintedW = snapPaintedExtent(m_viewportW + pad, dpr, true);
m_paintedH = snapPaintedExtent(m_viewportH, dpr, false);
setSize(QSizeF(paintedW, m_paintedH));
}
SparklineContent::LineGeometry SparklineContent::buildGeometry(
@@ -232,7 +246,8 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry(
// path stroker as garbage, which draws a shape that ignores the curve.
if (!buffer || buffer->count() < 2 || !std::isfinite(m_maxValue) ||
m_maxValue <= 0.0 || !std::isfinite(m_viewportW) ||
m_viewportW <= 0.0 || !std::isfinite(m_viewportH) || m_viewportH <= 0.0)
m_viewportW <= 0.0 || !std::isfinite(m_viewportH) || m_viewportH <= 0.0 ||
m_paintedH < 1.0)
return geom;
if (m_historyLength < 2) return geom;
@@ -241,18 +256,18 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry(
const qreal step = stepX();
if (!std::isfinite(step) || step <= 0.0) return geom;
const qint64 stepDev = toDev(step, dpr);
const qint64 lineDev = std::isfinite(m_lineWidth)
? (std::max)(qint64(1), toDev(m_lineWidth, dpr))
: qint64(1);
const qint64 heightDev = toDev(m_viewportH, dpr);
// The painted height, not the slot's: it is the number of texture rows that
// actually exist, so the plot and the fill bottom match them exactly.
const qint64 heightDev = toDev(m_paintedH, dpr);
qreal startX = 0.0;
if (m_sliding)
startX = m_viewportW - (len - 1) * step + step;
else
startX = 0.0;
const qint64 startDev = toDev(startX, dpr);
// Plot bounds in device pixels: half a stroke plus the curve overshoot at
// the top, a half stroke and one pixel of antialiasing feather at the
@@ -276,42 +291,27 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry(
const qreal value =
std::isfinite(raw) ? qBound<qreal>(0.0, raw, m_maxValue) : 0.0;
// Vertices on a pixel column, and on the y grid the stroke width needs.
// Item-local, so the parent's placement cannot move the shape. A flat run
// then lands on whole rows; slopes are interpolated, not snapped.
// The y of every sample goes on the grid the stroke width needs; x stays
// exact so the window's span is exactly the slot's width.
const qreal y = fromDev(
plotTopDev + static_cast<qint64>(std::llround(
(1.0 - value / m_maxValue) *
static_cast<double>(plotHDev))),
dpr);
points.append(QPointF(
fromDev(startDev + qint64(i) * stepDev, dpr),
snapLine(y, lineDev, dpr)));
points.append(QPointF(startX + qreal(i) * step, snapLine(y, lineDev, dpr)));
}
// One vertex per device-pixel column, shared with LineGraphItem through
// internal::curveVertices: a segment spanning several columns would otherwise
// interpolate its edge across all of them. y stays continuous, so a slope is
// interpolated rather than stepped one pixel at a time.
const QVector<QPointF> verts =
internal::curveVertices(points, dpr, m_curveSmoothing);
QPainterPath linePath;
linePath.moveTo(points.first());
for (int i = 0; i < points.size() - 1; ++i) {
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
QPointF c1;
QPointF c2;
segmentControls(points, i, m_curveSmoothing, c1, c2);
// One vertex per device pixel column, as Components/LineGraph.qml does: a
// segment spanning several columns would otherwise interpolate its edge
// across all of them. y stays continuous here - snapping it per column
// would step the slope one pixel at a time and read as a staircase.
const qint64 firstCol = std::llround(p1.x() * dpr) + 1;
const qint64 lastCol = std::llround(p2.x() * dpr) - 1;
for (qint64 col = firstCol; col <= lastCol; ++col) {
const qreal x = fromDev(col, dpr);
linePath.lineTo(x, cubicY(p1, c1, c2, p2, x));
}
linePath.lineTo(p2);
}
linePath.moveTo(verts.first());
for (int i = 1; i < verts.size(); ++i)
linePath.lineTo(verts[i]);
geom.stroke = linePath;
@@ -1,6 +1,7 @@
#pragma once
#include <qcolor.h>
#include <qelapsedtimer.h>
#include <qpainterpath.h>
#include <qquickpainteditem.h>
@@ -30,6 +31,15 @@ class SparklineContent : public QQuickPaintedItem {
void setViewportSize(qreal viewportWidth, qreal viewportHeight);
// How often a new sample arrives, in milliseconds. With this set the slide
// phase is read from the sample clock rather than from `progress`, which
// makes the wrap exact; without it the phase is the value QML supplies.
void setSlideInterval(qreal intervalMs);
// Offsets the item for the slide. Kept here rather than in SparklineItem
// because the quantization needs both stepX() and the window's dpr.
void setSlideProgress(qreal progress);
[[nodiscard]] qreal stepX() const;
protected:
@@ -44,12 +54,15 @@ class SparklineContent : public QQuickPaintedItem {
bool hasData = false;
};
[[nodiscard]] qreal slidePhase(qreal progress) const;
void invalidate();
void rebuildIfNeeded();
[[nodiscard]] LineGeometry buildGeometry(
internal::CircularBuffer* buffer) const;
void connectBuffer(internal::CircularBuffer* buffer);
void applyGeometry();
void onSampleArrived();
internal::CircularBuffer* m_line1 = nullptr;
internal::CircularBuffer* m_line2 = nullptr;
@@ -65,7 +78,14 @@ class SparklineContent : public QQuickPaintedItem {
qreal m_viewportW = 0.0;
qreal m_viewportH = 0.0;
// The height actually painted, snapped so the texture maps 1:1 onto device
// rows. The plot bounds are built from this, not from the slot's height.
qreal m_paintedH = 0.0;
qreal m_dpr = 1.0;
qreal m_slideIntervalMs = 0.0;
// Restarted whenever a sample arrives, so the slide phase follows the sample
// cadence instead of the animation's timeline.
QElapsedTimer m_sampleClock;
LineGeometry m_geom1;
LineGeometry m_geom2;
+16 -2
View File
@@ -19,7 +19,7 @@ void SparklineItem::geometryChange(
}
void SparklineItem::updateContentOffset() {
m_content->setX(-m_content->stepX() * m_slideProgress);
m_content->setSlideProgress(m_slideProgress);
}
void SparklineItem::applySmoothingRadius(
@@ -111,12 +111,26 @@ qreal SparklineItem::slideProgress() const {
}
void SparklineItem::setSlideProgress(qreal progress) {
if (qFuzzyCompare(m_slideProgress, progress)) return;
// Always forwarded: with a slide interval set the phase comes from the sample
// clock, so the offset can move even when the animation value repeats. An
// unchanged position is a no-op in QQuickItem.
m_slideProgress = progress;
updateContentOffset();
emit slideProgressChanged();
}
qreal SparklineItem::slideInterval() const {
return m_slideInterval;
}
void SparklineItem::setSlideInterval(qreal intervalMs) {
if (qFuzzyCompare(m_slideInterval, intervalMs)) return;
m_slideInterval = intervalMs;
m_content->setSlideInterval(intervalMs);
updateContentOffset();
emit slideIntervalChanged();
}
int SparklineItem::historyLength() const {
return m_historyLength;
}
@@ -39,6 +39,9 @@ class SparklineItem : public QQuickItem {
Q_PROPERTY(
qreal slideProgress READ slideProgress WRITE setSlideProgress NOTIFY
slideProgressChanged)
Q_PROPERTY(
qreal slideInterval READ slideInterval WRITE setSlideInterval NOTIFY
slideIntervalChanged)
Q_PROPERTY(
int historyLength READ historyLength WRITE setHistoryLength NOTIFY
historyLengthChanged)
@@ -80,6 +83,9 @@ class SparklineItem : public QQuickItem {
[[nodiscard]] qreal slideProgress() const;
void setSlideProgress(qreal progress);
[[nodiscard]] qreal slideInterval() const;
void setSlideInterval(qreal intervalMs);
[[nodiscard]] int historyLength() const;
void setHistoryLength(int length);
@@ -104,6 +110,7 @@ class SparklineItem : public QQuickItem {
void line2FillAlphaChanged();
void maxValueChanged();
void slideProgressChanged();
void slideIntervalChanged();
void historyLengthChanged();
void lineWidthChanged();
void slidingChanged();
@@ -127,6 +134,7 @@ class SparklineItem : public QQuickItem {
qreal m_line2FillAlpha = 0.2;
qreal m_maxValue = 1024.0;
qreal m_slideProgress = 0.0;
qreal m_slideInterval = 0.0;
int m_historyLength = 30;
qreal m_lineWidth = 2.0;
bool m_sliding = true;
@@ -1,449 +0,0 @@
#include "sparklinesceneitem.hpp"
#include <qmath.h>
#include <qpainterpath.h>
#include <qsgflatcolormaterial.h>
#include <qsggeometry.h>
namespace ZShell::components {
SparklineSceneItem::SparklineSceneItem(QQuickItem* parent)
: QQuickItem(parent) {
setClip(true);
setFlag(QQuickItem::ItemHasContents, true);
}
void SparklineSceneItem::geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) {
QQuickItem::geometryChange(newGeometry, oldGeometry);
if (newGeometry.size() != oldGeometry.size()) invalidateGeometry();
}
void SparklineSceneItem::invalidateGeometry() {
m_geometryDirty = true;
update();
}
void SparklineSceneItem::applySmoothingRadius(
internal::CircularBuffer* buffer) const {
if (buffer) buffer->setSmoothingRadius(qRound(m_smoothing));
}
void SparklineSceneItem::connectBuffer(internal::CircularBuffer* buffer) {
if (!buffer) return;
connect(
buffer,
&internal::CircularBuffer::valuesChanged,
this,
&SparklineSceneItem::invalidateGeometry);
connect(buffer, &QObject::destroyed, this, [this, buffer]() {
if (m_line1 == buffer) {
m_line1 = nullptr;
emit line1Changed();
}
if (m_line2 == buffer) {
m_line2 = nullptr;
emit line2Changed();
}
invalidateGeometry();
});
}
qreal SparklineSceneItem::stepX() const {
if (m_historyLength < 2 || width() <= 0.0) return 0.0;
if (m_sliding) return width() / static_cast<qreal>(m_historyLength - 1);
const int len = qMax(
2,
(m_line1 && m_line1->count() >= 2) ? m_line1->count()
: (m_line2 ? m_line2->count() : 2));
return width() / static_cast<qreal>(len - 1);
}
SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
internal::CircularBuffer* buffer) const {
LineSample sample;
if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || width() <= 0.0)
return sample;
if (m_historyLength < 2) return sample;
const int len = buffer->count();
const qreal step = stepX();
if (step <= 0.0) return sample;
qreal startX = m_sliding ? (width() - (len - 1) * step + step) : 0.0;
const qreal h = height();
const qreal strokePad = qCeil(m_lineWidth / 2);
const qreal curvePad = 3.0;
const qreal plotTop = strokePad + curvePad;
const qreal plotBottom = h - strokePad;
const qreal plotH = qMax<qreal>(1.0, plotBottom - plotTop);
QVector<QPointF> points;
points.reserve(len);
for (int i = 0; i < len; ++i) {
const qreal value =
qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue);
const qreal x = startX + i * step;
const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH;
points.append(QPointF(x, y));
}
QPainterPath path;
path.moveTo(points[0]);
if (m_curveSmoothing && points.size() > 2) {
const qreal tension = 1.0;
const qreal k = tension / 6.0;
const int n = static_cast<int>(points.size());
for (int i = 0; i < n - 1; ++i) {
const QPointF& p0 = points[qMax(i - 1, 0)];
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
const QPointF& p3 = points[qMin(i + 2, n - 1)];
QPointF c1 = p1 + (p2 - p0) * k;
QPointF c2 = p2 - (p3 - p1) * k;
const qreal segMinY = qMin(p1.y(), p2.y());
const qreal segMaxY = qMax(p1.y(), p2.y());
c1.setY(qBound(segMinY, c1.y(), segMaxY));
c2.setY(qBound(segMinY, c2.y(), segMaxY));
path.cubicTo(c1, c2, p2);
}
} else {
for (int i = 0; i < points.size() - 1; ++i) {
const QPointF& p0 = points[i];
const QPointF& p1 = points[i + 1];
const qreal ctrlX = (p0.x() + p1.x()) * 0.5;
path.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1);
}
}
const auto subpaths = path.toSubpathPolygons();
if (subpaths.isEmpty()) return sample;
sample.polyline = subpaths.first();
sample.fillBottom = h;
sample.hasData = sample.polyline.size() >= 2;
return sample;
}
void SparklineSceneItem::writeFillGeometry(
QSGGeometryNode* node, const LineSample& sample) {
const int n = static_cast<int>(sample.polyline.size());
auto* geometry = node->geometry();
geometry->allocate(n * 2);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
auto* vertices = geometry->vertexDataAsPoint2D();
for (int i = 0; i < n; ++i) {
const QPointF& p = sample.polyline[i];
vertices[i * 2].set(
static_cast<float>(p.x()), static_cast<float>(p.y()));
vertices[i * 2 + 1].set(
static_cast<float>(p.x()), static_cast<float>(sample.fillBottom));
}
node->markDirty(QSGNode::DirtyGeometry);
}
void SparklineSceneItem::writeStrokeGeometry(
QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) {
const int n = static_cast<int>(sample.polyline.size());
auto* geometry = node->geometry();
geometry->allocate(n * 2);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
auto* vertices = geometry->vertexDataAsPoint2D();
const qreal half = lineWidth * 0.5;
for (int i = 0; i < n; ++i) {
QPointF dir;
if (i == 0)
dir = sample.polyline[1] - sample.polyline[0];
else if (i == n - 1)
dir = sample.polyline[n - 1] - sample.polyline[n - 2];
else
dir = sample.polyline[i + 1] - sample.polyline[i - 1];
const qreal len = std::hypot(dir.x(), dir.y());
QPointF normal = len > 1e-6 ? QPointF(-dir.y() / len, dir.x() / len)
: QPointF(0, 1);
const QPointF& p = sample.polyline[i];
const QPointF offset = normal * half;
vertices[i * 2].set(
static_cast<float>(p.x() + offset.x()),
static_cast<float>(p.y() + offset.y()));
vertices[i * 2 + 1].set(
static_cast<float>(p.x() - offset.x()),
static_cast<float>(p.y() - offset.y()));
}
node->markDirty(QSGNode::DirtyGeometry);
}
void SparklineSceneItem::setMaterialColor(
QSGGeometryNode* node, const QColor& color, qreal alpha) {
auto* material = static_cast<QSGFlatColorMaterial*>(node->material());
QColor c = color;
c.setAlphaF(static_cast<float>(alpha));
material->setColor(c);
node->markDirty(QSGNode::DirtyMaterial);
}
QSGNode* SparklineSceneItem::updatePaintNode(
QSGNode* node, UpdatePaintNodeData*) {
bool samplesRebuilt = false;
if (m_geometryDirty) {
m_sample1 = buildSample(m_line1);
m_sample2 = buildSample(m_line2);
m_geometryDirty = false;
samplesRebuilt = true;
}
const bool hasAny = m_sample1.hasData || m_sample2.hasData;
if (!hasAny) {
delete node;
m_transformNode = nullptr;
m_line1FillNode = m_line1StrokeNode = nullptr;
m_line2FillNode = m_line2StrokeNode = nullptr;
return nullptr;
}
auto* transformNode = static_cast<QSGTransformNode*>(node);
bool justCreated = false;
if (!transformNode) {
transformNode = new QSGTransformNode();
m_transformNode = transformNode;
auto makeNode = [] {
auto* n = new QSGGeometryNode();
n->setGeometry(
new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 0));
n->setFlag(QSGNode::OwnsGeometry);
n->setMaterial(new QSGFlatColorMaterial());
n->setFlag(QSGNode::OwnsMaterial);
return n;
};
m_line1FillNode = makeNode();
m_line1StrokeNode = makeNode();
m_line2FillNode = makeNode();
m_line2StrokeNode = makeNode();
transformNode->appendChildNode(m_line1FillNode);
transformNode->appendChildNode(m_line1StrokeNode);
transformNode->appendChildNode(m_line2FillNode);
transformNode->appendChildNode(m_line2StrokeNode);
justCreated = true;
}
QMatrix4x4 matrix;
matrix.translate(static_cast<float>(-stepX() * m_slideProgress), 0.0f);
transformNode->setMatrix(matrix);
const bool needsGeometryWrite = samplesRebuilt || justCreated;
const bool needsColorWrite = m_colorsDirty || justCreated;
auto writeLine = [&](QSGGeometryNode* fillNode,
QSGGeometryNode* strokeNode,
const LineSample& sample,
const QColor& color,
qreal fillAlpha) {
if (needsGeometryWrite) {
if (sample.hasData) {
writeFillGeometry(fillNode, sample);
writeStrokeGeometry(strokeNode, sample, m_lineWidth);
} else {
fillNode->geometry()->allocate(0);
strokeNode->geometry()->allocate(0);
fillNode->markDirty(QSGNode::DirtyGeometry);
strokeNode->markDirty(QSGNode::DirtyGeometry);
}
}
if (needsColorWrite) {
setMaterialColor(fillNode, color, fillAlpha);
setMaterialColor(strokeNode, color, 1.0);
}
};
writeLine(
m_line1FillNode,
m_line1StrokeNode,
m_sample1,
m_line1Color,
m_line1FillAlpha);
writeLine(
m_line2FillNode,
m_line2StrokeNode,
m_sample2,
m_line2Color,
m_line2FillAlpha);
m_colorsDirty = false;
return transformNode;
}
internal::CircularBuffer* SparklineSceneItem::line1() const {
return m_line1;
}
void SparklineSceneItem::setLine1(internal::CircularBuffer* buffer) {
if (m_line1 == buffer) return;
if (m_line1) disconnect(m_line1, nullptr, this, nullptr);
m_line1 = buffer;
connectBuffer(buffer);
applySmoothingRadius(buffer);
invalidateGeometry();
emit line1Changed();
}
internal::CircularBuffer* SparklineSceneItem::line2() const {
return m_line2;
}
void SparklineSceneItem::setLine2(internal::CircularBuffer* buffer) {
if (m_line2 == buffer) return;
if (m_line2) disconnect(m_line2, nullptr, this, nullptr);
m_line2 = buffer;
connectBuffer(buffer);
applySmoothingRadius(buffer);
invalidateGeometry();
emit line2Changed();
}
QColor SparklineSceneItem::line1Color() const {
return m_line1Color;
}
void SparklineSceneItem::setLine1Color(const QColor& color) {
if (m_line1Color == color) return;
m_line1Color = color;
m_colorsDirty = true;
update();
emit line1ColorChanged();
}
QColor SparklineSceneItem::line2Color() const {
return m_line2Color;
}
void SparklineSceneItem::setLine2Color(const QColor& color) {
if (m_line2Color == color) return;
m_line2Color = color;
m_colorsDirty = true;
update();
emit line2ColorChanged();
}
qreal SparklineSceneItem::line1FillAlpha() const {
return m_line1FillAlpha;
}
void SparklineSceneItem::setLine1FillAlpha(qreal alpha) {
if (qFuzzyCompare(m_line1FillAlpha, alpha)) return;
m_line1FillAlpha = alpha;
m_colorsDirty = true;
update();
emit line1FillAlphaChanged();
}
qreal SparklineSceneItem::line2FillAlpha() const {
return m_line2FillAlpha;
}
void SparklineSceneItem::setLine2FillAlpha(qreal alpha) {
if (qFuzzyCompare(m_line2FillAlpha, alpha)) return;
m_line2FillAlpha = alpha;
m_colorsDirty = true;
update();
emit line2FillAlphaChanged();
}
qreal SparklineSceneItem::maxValue() const {
return m_maxValue;
}
void SparklineSceneItem::setMaxValue(qreal value) {
if (qFuzzyCompare(m_maxValue, value)) return;
m_maxValue = value;
invalidateGeometry();
emit maxValueChanged();
}
qreal SparklineSceneItem::slideProgress() const {
return m_slideProgress;
}
void SparklineSceneItem::setSlideProgress(qreal progress) {
if (qFuzzyCompare(m_slideProgress, progress)) return;
m_slideProgress = progress;
update();
emit slideProgressChanged();
}
int SparklineSceneItem::historyLength() const {
return m_historyLength;
}
void SparklineSceneItem::setHistoryLength(int length) {
if (m_historyLength == length) return;
m_historyLength = length;
invalidateGeometry();
emit historyLengthChanged();
}
qreal SparklineSceneItem::lineWidth() const {
return m_lineWidth;
}
void SparklineSceneItem::setLineWidth(qreal width) {
if (qFuzzyCompare(m_lineWidth, width)) return;
m_lineWidth = width;
invalidateGeometry();
emit lineWidthChanged();
}
bool SparklineSceneItem::sliding() const {
return m_sliding;
}
void SparklineSceneItem::setSliding(bool sliding) {
if (m_sliding == sliding) return;
m_sliding = sliding;
invalidateGeometry();
emit slidingChanged();
}
qreal SparklineSceneItem::smoothing() const {
return m_smoothing;
}
void SparklineSceneItem::setSmoothing(qreal smoothing) {
smoothing = qMax<qreal>(0.0, smoothing);
if (qFuzzyCompare(m_smoothing, smoothing)) return;
m_smoothing = smoothing;
applySmoothingRadius(m_line1);
applySmoothingRadius(m_line2);
emit smoothingChanged();
}
bool SparklineSceneItem::curveSmoothing() const {
return m_curveSmoothing;
}
void SparklineSceneItem::setCurveSmoothing(bool enabled) {
if (m_curveSmoothing == enabled) return;
m_curveSmoothing = enabled;
invalidateGeometry();
emit curveSmoothingChanged();
}
} // namespace ZShell::components
@@ -1,167 +0,0 @@
#pragma once
#include <qcolor.h>
#include <qmatrix4x4.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qquickitem.h>
#include <qtmetamacros.h>
#include <qvector.h>
#include "internal/circularbuffer.hpp"
class QSGGeometryNode;
class QSGTransformNode;
namespace ZShell::components {
class SparklineSceneItem : public QQuickItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
internal::CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY
line1Changed)
Q_PROPERTY(
internal::CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY
line2Changed)
Q_PROPERTY(
QColor line1Color READ line1Color WRITE setLine1Color NOTIFY
line1ColorChanged)
Q_PROPERTY(
QColor line2Color READ line2Color WRITE setLine2Color NOTIFY
line2ColorChanged)
Q_PROPERTY(
qreal line1FillAlpha READ line1FillAlpha WRITE setLine1FillAlpha NOTIFY
line1FillAlphaChanged)
Q_PROPERTY(
qreal line2FillAlpha READ line2FillAlpha WRITE setLine2FillAlpha NOTIFY
line2FillAlphaChanged)
Q_PROPERTY(
qreal maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged)
Q_PROPERTY(
qreal slideProgress READ slideProgress WRITE setSlideProgress NOTIFY
slideProgressChanged)
Q_PROPERTY(
int historyLength READ historyLength WRITE setHistoryLength NOTIFY
historyLengthChanged)
Q_PROPERTY(
qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY
lineWidthChanged)
Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged)
Q_PROPERTY(
qreal smoothing READ smoothing WRITE setSmoothing NOTIFY
smoothingChanged)
Q_PROPERTY(
bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY
curveSmoothingChanged)
public:
explicit SparklineSceneItem(QQuickItem* parent = nullptr);
[[nodiscard]] internal::CircularBuffer* line1() const;
void setLine1(internal::CircularBuffer* buffer);
[[nodiscard]] internal::CircularBuffer* line2() const;
void setLine2(internal::CircularBuffer* buffer);
[[nodiscard]] QColor line1Color() const;
void setLine1Color(const QColor& color);
[[nodiscard]] QColor line2Color() const;
void setLine2Color(const QColor& color);
[[nodiscard]] qreal line1FillAlpha() const;
void setLine1FillAlpha(qreal alpha);
[[nodiscard]] qreal line2FillAlpha() const;
void setLine2FillAlpha(qreal alpha);
[[nodiscard]] qreal maxValue() const;
void setMaxValue(qreal value);
[[nodiscard]] qreal slideProgress() const;
void setSlideProgress(qreal progress);
[[nodiscard]] int historyLength() const;
void setHistoryLength(int length);
[[nodiscard]] qreal lineWidth() const;
void setLineWidth(qreal width);
[[nodiscard]] bool sliding() const;
void setSliding(bool sliding);
[[nodiscard]] qreal smoothing() const;
void setSmoothing(qreal smoothing);
[[nodiscard]] bool curveSmoothing() const;
void setCurveSmoothing(bool curveSmoothing);
signals:
void line1Changed();
void line2Changed();
void line1ColorChanged();
void line2ColorChanged();
void line1FillAlphaChanged();
void line2FillAlphaChanged();
void maxValueChanged();
void slideProgressChanged();
void historyLengthChanged();
void lineWidthChanged();
void slidingChanged();
void smoothingChanged();
void curveSmoothingChanged();
protected:
QSGNode* updatePaintNode(QSGNode* node, UpdatePaintNodeData* data) override;
void geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) override;
private:
struct LineSample {
QVector<QPointF> polyline; // local coords, slideProgress baked to 0
qreal fillBottom = 0.0;
bool hasData = false;
};
void connectBuffer(internal::CircularBuffer* buffer);
void invalidateGeometry();
void applySmoothingRadius(internal::CircularBuffer* buffer) const;
[[nodiscard]] qreal stepX() const;
[[nodiscard]] LineSample buildSample(internal::CircularBuffer* buffer) const;
static void writeFillGeometry(
QSGGeometryNode* node, const LineSample& sample);
static void writeStrokeGeometry(
QSGGeometryNode* node, const LineSample& sample, qreal lineWidth);
static void setMaterialColor(
QSGGeometryNode* node, const QColor& color, qreal alpha);
internal::CircularBuffer* m_line1 = nullptr;
internal::CircularBuffer* m_line2 = nullptr;
QColor m_line1Color;
QColor m_line2Color;
qreal m_line1FillAlpha = 0.15;
qreal m_line2FillAlpha = 0.2;
qreal m_maxValue = 1024.0;
qreal m_slideProgress = 0.0;
int m_historyLength = 30;
qreal m_lineWidth = 2.0;
bool m_sliding = true;
qreal m_smoothing = 0.0;
bool m_curveSmoothing = false;
LineSample m_sample1;
LineSample m_sample2;
bool m_geometryDirty = true;
bool m_colorsDirty = true;
QSGTransformNode* m_transformNode = nullptr;
QSGGeometryNode* m_line1FillNode = nullptr;
QSGGeometryNode* m_line1StrokeNode = nullptr;
QSGGeometryNode* m_line2FillNode = nullptr;
QSGGeometryNode* m_line2StrokeNode = nullptr;
};
} // namespace ZShell::components
+1
View File
@@ -7,6 +7,7 @@ qml_module(ZShell-internal
cachingimagemanager.hpp cachingimagemanager.cpp
circularindicatormanager.hpp circularindicatormanager.cpp
circularbuffer.hpp circularbuffer.cpp
linegeometry.hpp linegeometry.cpp
arcgauge.hpp arcgauge.cpp
lidwatcher.hpp lidwatcher.cpp
visualizerbars.hpp visualizerbars.cpp
+147
View File
@@ -0,0 +1,147 @@
#include "linegeometry.hpp"
#include <qglobal.h>
#include <algorithm>
namespace ZShell::internal {
qreal snapLine(qreal logical, qint64 widthDev, qreal dpr) {
const qreal half = qreal(widthDev) / 2.0;
return (static_cast<qreal>(std::llround(logical * dpr - half)) + half)
/ dpr;
}
void segmentControls(
const QVector<QPointF>& points,
int i,
bool curveSmoothing,
QPointF& c1,
QPointF& c2) {
const QPointF& p0 = points[qMax(i - 1, 0)];
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
const QPointF& p3 = points[qMin(i + 2, points.size() - 1)];
if (curveSmoothing && points.size() > 2) {
const qreal k = 1.0 / 6.0;
c1 = p1 + (p2 - p0) * k;
c2 = p2 - (p3 - p1) * k;
const qreal minY = qMin(p1.y(), p2.y());
const qreal maxY = qMax(p1.y(), p2.y());
c1.setY(qBound(minY, c1.y(), maxY));
c2.setY(qBound(minY, c2.y(), maxY));
return;
}
const qreal cx = (p1.x() + p2.x()) * 0.5;
c1 = QPointF(cx, p1.y());
c2 = QPointF(cx, p2.y());
}
qreal cubicY(
const QPointF& p1,
const QPointF& c1,
const QPointF& c2,
const QPointF& p2,
qreal targetX) {
qreal lo = 0.0;
qreal hi = 1.0;
for (int it = 0; it < 16; ++it) {
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
const qreal x = u * u * u * p1.x() + 3 * u * u * t * c1.x()
+ 3 * u * t * t * c2.x() + t * t * t * p2.x();
if (x < targetX)
lo = t;
else
hi = t;
}
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
return u * u * u * p1.y() + 3 * u * u * t * c1.y()
+ 3 * u * t * t * c2.y() + t * t * t * p2.y();
}
namespace {
// Index of the segment whose right endpoint is at or past targetX, i.e. the
// segment that contains it. Clamps to the last segment when targetX is beyond the
// end. Assumes points ascend in x.
int segmentIndexAt(const QVector<QPointF>& points, qreal targetX) {
const int last = points.size() - 2;
for (int i = 0; i < points.size() - 1; ++i) {
if (targetX <= points[i + 1].x())
return i;
}
return qMax(0, last);
}
} // namespace
QPointF pointAtX(
const QVector<QPointF>& points,
qreal targetX,
bool curveSmoothing) {
const int j = segmentIndexAt(points, targetX);
QPointF c1;
QPointF c2;
segmentControls(points, j, curveSmoothing, c1, c2);
qreal lo = 0.0;
qreal hi = 1.0;
for (int it = 0; it < 24; ++it) {
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
const qreal x = u * u * u * points[j].x()
+ 3 * u * u * t * c1.x() + 3 * u * t * t * c2.x()
+ t * t * t * points[j + 1].x();
if (x < targetX)
lo = t;
else
hi = t;
}
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
const qreal y = u * u * u * points[j].y() + 3 * u * u * t * c1.y()
+ 3 * u * t * t * c2.y() + t * t * t * points[j + 1].y();
return QPointF(targetX, y);
}
QVector<QPointF> curveVertices(
const QVector<QPointF>& points,
qreal dpr,
bool curveSmoothing) {
QVector<QPointF> out;
if (points.size() < 2)
return out;
out.reserve(points.size());
out.append(points.first());
for (int i = 0; i < points.size() - 1; ++i) {
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
QPointF c1;
QPointF c2;
segmentControls(points, i, curveSmoothing, c1, c2);
// One vertex per device-pixel column. A segment spanning several columns
// would otherwise interpolate its edge across all of them, which reads as
// a soft rather than crisply anti-aliased stroke. y stays continuous:
// snapping it per column would step the slope a pixel at a time and look
// like a staircase.
const qint64 firstCol = toDev(p1.x(), dpr) + 1;
const qint64 lastCol = toDev(p2.x(), dpr) - 1;
for (qint64 col = firstCol; col <= lastCol; ++col) {
const qreal x = fromDev(col, dpr);
out.append(QPointF(x, cubicY(p1, c1, c2, p2, x)));
}
out.append(p2);
}
return out;
}
} // namespace ZShell::internal
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <qpoint.h>
#include <qvector.h>
#include <cmath>
#include <cstdint>
namespace ZShell::internal {
// Shared curve construction for SparklineContent (QPainter) and LineGraphItem
// (QCanvasPainter). Input and output coordinates are logical (item-local);
// device-pixel quantization happens inside so a stroke lands on whole pixel
// columns and its edges are not interpolated across several columns. Keeping the
// math in one place means both graphs align and smooth their lines identically,
// whatever backend they paint through.
inline qint64 toDev(qreal logical, qreal dpr) {
return std::llround(logical * dpr);
}
inline qreal fromDev(qint64 device, qreal dpr) {
return qreal(device) / dpr;
}
// Snap a line center so a whole-pixel-wide stroke sits on pixel boundaries when
// the device width is even and on pixel centers when it is odd.
qreal snapLine(qreal logical, qint64 widthDev, qreal dpr);
// Catmull-Rom control points for the segment points[i] -> points[i + 1], with the
// control y clamped to the segment's own y span so a curve never overshoots past
// its endpoints. When curveSmoothing is off (or there are too few points) the
// tangents are flat.
void segmentControls(
const QVector<QPointF>& points,
int i,
bool curveSmoothing,
QPointF& c1,
QPointF& c2);
// y of the cubic bezier p1-c1-c2-p2 at targetX, found by bisection on t.
qreal cubicY(
const QPointF& p1,
const QPointF& c1,
const QPointF& c2,
const QPointF& p2,
qreal targetX);
// The curve point at targetX on the polyline, interpolating across the segment
// that contains it. Assumes points ascend in x.
QPointF pointAtX(
const QVector<QPointF>& points,
qreal targetX,
bool curveSmoothing);
// A dense polyline through points, adding one vertex per device-pixel column
// inside each segment so an edge never interpolates across several columns. The
// result starts at points.first() and ends at points.last(). points must ascend in
// x and hold at least two entries. Backends consume it as a moveTo/lineTo chain.
QVector<QPointF> curveVertices(
const QVector<QPointF>& points,
qreal dpr,
bool curveSmoothing);
} // namespace ZShell::internal