diff --git a/Components/LineGraph.qml b/Components/LineGraph.qml deleted file mode 100644 index 81a8954..0000000 --- a/Components/LineGraph.qml +++ /dev/null @@ -1,506 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtCanvas2D -import ZShell - -Item { - id: root - - required property list values - - property int xSteps: 24 - property real yMin: NaN - property real yMax: NaN - property real yPaddingTop: 0 - property real yPaddingBottom: 0 - - property int verticalLineStep: 0 - property int horizontalLineStep: 0 - property bool firstXLine: true - property bool lastXLine: false - property int minHorizontalLines: 0 - - property var xLabel: null - property var yLabel: null - - property real smoothing: 0 - property bool curveSmoothing: true - property real markerX: -1 - property real markerGap: 2 // px gap between line and marker dot edge, each side - - property color lineColor: "white" - property color gridColor: "white" - property color markerColor: "white" - property color labelColor: "white" - property real gridAlpha: 0.12 - property real labelAlpha: 0.6 - property real fillAlpha: 0.15 - property real lineWidth: 2 - property real markerDotRadius: 4 - property real labelFontSize: 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() -} diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml index 5c311e8..7cacb0c 100644 --- a/Modules/Dashboard/Processes/SystemInfo.qml +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -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 diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml index ca33e80..d8f7668 100644 --- a/Modules/Dashboard/Weather/Graphs.qml +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -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 diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index 61ccd27..7b17acc 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -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 diff --git a/Modules/Resources/Content.qml b/Modules/Resources/Content.qml index a813746..e367da2 100644 --- a/Modules/Resources/Content.qml +++ b/Modules/Resources/Content.qml @@ -120,6 +120,12 @@ Item { label: qsTr("Video memory") icon: "memory_alt" } + + StateLayer { + onClicked: { + ZUtils.logPlacement(card, "card"); + } + } } WrappedLoader { diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 56b574b..cec60e2 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -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 diff --git a/Plugins/ZShell/Components/linegraph.cpp b/Plugins/ZShell/Components/linegraph.cpp new file mode 100644 index 0000000..2d0c03f --- /dev/null +++ b/Plugins/ZShell/Components/linegraph.cpp @@ -0,0 +1,910 @@ +#include "linegraph.hpp" + +#include "internal/linegeometry.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace ZShell::components { + +namespace { +constexpr qreal kNaN = std::numeric_limits::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& 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(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 cutLeft( + const QVector& 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 left = points.first(j + 1); + if (targetX > points[j].x()) + left.append(internal::pointAtX(points, targetX, curveSmoothing)); + return left; +} + +QVector cutRight( + const QVector& 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 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 fill; + QVector> strokes; + }; + + struct Marker { + bool hasLine = false; + qreal lineX = 0.0; + QVector> 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 vGrid; + QVector hGrid; + QVector