#include "sparklinecontent.hpp" #include "internal/linegeometry.hpp" #include #include #include #include #include #include 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 { // 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; } // 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); } void SparklineContent::itemChange( ItemChange change, const ItemChangeData& value) { QQuickPaintedItem::itemChange(change, value); if (change == ItemSceneChange && value.window) m_dpr = value.window->effectiveDevicePixelRatio(); else if (change == ItemDevicePixelRatioHasChanged && window()) m_dpr = window()->effectiveDevicePixelRatio(); else return; invalidate(); } qreal SparklineContent::stepX() const { if (m_historyLength < 2 || m_viewportW <= 0.0) return 0.0; const int len = qMax( 2, (m_line1 && m_line1->count() >= 2) ? m_line1->count() : (m_line2 ? m_line2->count() : 2)); // 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(m_historyLength - 1) : m_viewportW / static_cast(len - 1); } void SparklineContent::setViewportSize( qreal viewportWidth, qreal viewportHeight) { if (qFuzzyCompare(m_viewportW, viewportWidth) && qFuzzyCompare(m_viewportH, viewportHeight)) return; m_viewportW = viewportWidth; m_viewportH = viewportHeight; 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(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); m_line1 = buffer; connectBuffer(buffer); invalidate(); } void SparklineContent::setLine2(internal::CircularBuffer* buffer) { if (m_line2 == buffer) return; if (m_line2) disconnect(m_line2, nullptr, this, nullptr); m_line2 = buffer; connectBuffer(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::onSampleArrived); connect(buffer, &QObject::destroyed, this, [this, buffer]() { if (m_line1 == buffer) m_line1 = nullptr; if (m_line2 == buffer) m_line2 = nullptr; invalidate(); }); } void SparklineContent::setLine1Color(const QColor& color) { if (m_line1Color == color) return; m_line1Color = color; update(); } void SparklineContent::setLine2Color(const QColor& color) { if (m_line2Color == color) return; m_line2Color = color; update(); } void SparklineContent::setLine1FillAlpha(qreal alpha) { if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; m_line1FillAlpha = alpha; update(); } void SparklineContent::setLine2FillAlpha(qreal alpha) { if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; m_line2FillAlpha = alpha; update(); } void SparklineContent::setMaxValue(qreal value) { if (qFuzzyCompare(m_maxValue, value)) return; m_maxValue = value; invalidate(); } void SparklineContent::setLineWidth(qreal width) { if (qFuzzyCompare(m_lineWidth, width)) return; m_lineWidth = width; invalidate(); } void SparklineContent::setCurveSmoothing(bool enabled) { if (m_curveSmoothing == enabled) return; m_curveSmoothing = enabled; invalidate(); } void SparklineContent::setHistoryLength(int length) { if (m_historyLength == length) return; m_historyLength = length; invalidate(); } void SparklineContent::setSliding(bool sliding) { if (m_sliding == sliding) return; m_sliding = sliding; invalidate(); } void SparklineContent::invalidate() { m_dirty = true; applyGeometry(); update(); } void SparklineContent::applyGeometry() { 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( internal::CircularBuffer* buffer) const { LineGeometry geom; // Every input has to be a usable number: a NaN from QML (an animated maxValue // fed by an empty buffer, for instance) would otherwise reach llround and the // 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_paintedH < 1.0) return geom; if (m_historyLength < 2) return geom; const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0; const int len = buffer->count(); const qreal step = stepX(); if (!std::isfinite(step) || step <= 0.0) return geom; const qint64 lineDev = std::isfinite(m_lineWidth) ? (std::max)(qint64(1), toDev(m_lineWidth, dpr)) : qint64(1); // 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; // 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 // bottom, so neither edge of the stroke is cut by the item's own rect. const qint64 curvePadDev = toDev(m_curveSmoothing ? 3.0 : 0.0, dpr); const qint64 plotTopDev = lineDev / 2 + curvePadDev; const qint64 plotBottomDev = (std::max)(plotTopDev + 1, heightDev - (lineDev + 1) / 2 - 1); const qint64 plotHDev = plotBottomDev - plotTopDev; const qreal fillBottom = fromDev(heightDev, dpr); QVector points; points.reserve(len); for (int i = 0; i < len; ++i) { const qreal raw = buffer->smoothedAt(i); // A non-finite sample poisons the whole path: llround of it is undefined, // and the stroke and fill then ignore everything after that vertex. Treat // it as no traffic instead. const qreal value = std::isfinite(raw) ? qBound(0.0, raw, m_maxValue) : 0.0; // 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(std::llround( (1.0 - value / m_maxValue) * static_cast(plotHDev))), 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 verts = internal::curveVertices(points, dpr, m_curveSmoothing); QPainterPath linePath; linePath.moveTo(verts.first()); for (int i = 1; i < verts.size(); ++i) linePath.lineTo(verts[i]); geom.stroke = linePath; QPainterPath fillPath = linePath; fillPath.lineTo(points.last().x(), fillBottom); fillPath.lineTo(points.first().x(), fillBottom); fillPath.closeSubpath(); geom.fill = fillPath; geom.hasData = true; return geom; } void SparklineContent::rebuildIfNeeded() { if (!m_dirty) return; m_geom1 = buildGeometry(m_line1); m_geom2 = buildGeometry(m_line2); m_dirty = false; } void SparklineContent::paint(QPainter* painter) { rebuildIfNeeded(); if (!m_geom1.hasData && !m_geom2.hasData) return; painter->setRenderHint(QPainter::Antialiasing, true); const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0; const qreal lineWidth = std::isfinite(m_lineWidth) ? fromDev((std::max)(qint64(1), toDev(m_lineWidth, dpr)), dpr) : 1.0; auto drawOne = [&](const LineGeometry& geom, const QColor& color, qreal fillAlpha) { if (!geom.hasData) return; QColor fillColor = color; fillColor.setAlphaF(static_cast(fillAlpha)); painter->setPen(Qt::NoPen); painter->setBrush(fillColor); painter->drawPath(geom.fill); QPen pen(color, lineWidth); pen.setCapStyle(Qt::RoundCap); pen.setJoinStyle(Qt::RoundJoin); painter->setPen(pen); painter->setBrush(Qt::NoBrush); painter->drawPath(geom.stroke); }; drawOne(m_geom1, m_line1Color, m_line1FillAlpha); drawOne(m_geom2, m_line2Color, m_line2FillAlpha); } } // namespace ZShell::components