491 lines
12 KiB
QML
491 lines
12 KiB
QML
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);
|
|
}
|
|
|
|
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 v of root.values) {
|
|
if (v == null)
|
|
continue;
|
|
if (v < dmin)
|
|
dmin = v;
|
|
if (v > dmax)
|
|
dmax = v;
|
|
}
|
|
if (dmin === Infinity) {
|
|
dmin = 0;
|
|
dmax = 1;
|
|
}
|
|
|
|
const step = root.horizontalLineStep;
|
|
const minSteps = root.minHorizontalLines > 1 ? root.minHorizontalLines - 1 : 0;
|
|
|
|
if (step > 0) {
|
|
if (hiAuto) {
|
|
const baseLo = loAuto ? Math.floor(dmin / step) * step : lo;
|
|
const span = Math.max(Math.ceil((dmax - baseLo) / step), minSteps, 1);
|
|
lo = baseLo;
|
|
hi = baseLo + span * step;
|
|
} else {
|
|
const span = Math.max(Math.ceil((hi - dmin) / step), minSteps, 1);
|
|
lo = hi - span * step;
|
|
}
|
|
} else {
|
|
if (loAuto)
|
|
lo = dmin;
|
|
if (hiAuto)
|
|
hi = dmax;
|
|
if (hi <= lo)
|
|
hi = lo + 1;
|
|
}
|
|
}
|
|
|
|
const scaleBottom = lo - root.yPaddingBottom;
|
|
const scaleTop = hi + root.yPaddingTop;
|
|
const range = scaleTop - scaleBottom;
|
|
if (range <= 0 || root.xSteps < 1)
|
|
return;
|
|
|
|
let series = root.values;
|
|
if (root.smoothing > 0 && series.length > 0) {
|
|
const radius = Math.round(root.smoothing);
|
|
const raw = series;
|
|
series = raw.map((v, i) => {
|
|
if (v == null)
|
|
return null;
|
|
|
|
let sum = 0;
|
|
let n = 0;
|
|
for (let j = -radius; j <= radius; j++) {
|
|
const u = raw[i + j];
|
|
if (u != null) {
|
|
sum += u;
|
|
n++;
|
|
}
|
|
}
|
|
return n > 0 ? sum / n : null;
|
|
});
|
|
}
|
|
|
|
const xAt = i => plot.x + (i / root.xSteps) * plot.w;
|
|
const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h;
|
|
|
|
const 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()
|
|
}
|