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
+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;