mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
370 lines
11 KiB
QML
370 lines
11 KiB
QML
import QtQuick
|
||
import QtQuick.Shapes
|
||
import qs.Commons
|
||
|
||
Item {
|
||
id: root
|
||
clip: true
|
||
|
||
// Primary line
|
||
property var values: []
|
||
property color color: Color.mPrimary
|
||
|
||
// Optional secondary line
|
||
property var values2: []
|
||
property color color2: Color.mError
|
||
|
||
// Range settings for primary line
|
||
property real minValue: 0
|
||
property real maxValue: 100
|
||
|
||
// Range settings for secondary line (defaults to primary range)
|
||
property real minValue2: minValue
|
||
property real maxValue2: maxValue
|
||
|
||
// Style settings
|
||
property real strokeWidth: 2 * Style.uiScaleRatio
|
||
property bool fill: true
|
||
property real fillOpacity: 0.15
|
||
|
||
// Smooth scrolling interval (how often data updates)
|
||
property int updateInterval: 1000
|
||
|
||
// Animate scale changes (for network graphs with dynamic max)
|
||
property bool animateScale: false
|
||
|
||
// Vertical padding (percentage of range) to keep values from touching edges
|
||
readonly property real curvePadding: 0.12
|
||
|
||
readonly property bool hasData: values.length >= 4
|
||
readonly property bool hasData2: values2.length >= 4
|
||
|
||
// Target max values (what we're animating toward)
|
||
property real _targetMax1: maxValue
|
||
property real _targetMax2: maxValue2
|
||
|
||
// Current animated max values (interpolated in timer when animateScale is true)
|
||
property real _animMax1: maxValue
|
||
property real _animMax2: maxValue2
|
||
|
||
onMaxValueChanged: {
|
||
_targetMax1 = maxValue;
|
||
if (animateScale && _ready1) {
|
||
_animTimer.start();
|
||
} else {
|
||
_animMax1 = maxValue;
|
||
}
|
||
}
|
||
|
||
onMaxValue2Changed: {
|
||
_targetMax2 = maxValue2;
|
||
if (animateScale && _ready2) {
|
||
_animTimer.start();
|
||
} else {
|
||
_animMax2 = maxValue2;
|
||
}
|
||
}
|
||
|
||
// Effective max values (animated or direct)
|
||
readonly property real _effectiveMax1: animateScale ? _animMax1 : maxValue
|
||
readonly property real _effectiveMax2: animateScale ? _animMax2 : maxValue2
|
||
|
||
// Animation state for primary line
|
||
property real _t1: 1.0
|
||
property bool _ready1: false
|
||
property real _pred1: 0
|
||
|
||
// Animation state for secondary line
|
||
property real _t2: 1.0
|
||
property bool _ready2: false
|
||
property real _pred2: 0
|
||
|
||
onValuesChanged: {
|
||
if (values.length < 4)
|
||
return;
|
||
|
||
const last = values[values.length - 1];
|
||
const prev = values[values.length - 2];
|
||
_pred1 = Math.max(minValue, last + (last - prev));
|
||
|
||
if (!_ready1) {
|
||
_ready1 = true;
|
||
_t1 = 0;
|
||
} else {
|
||
_t1 = Math.max(0, _t1 - 1.0);
|
||
}
|
||
_animTimer.start();
|
||
}
|
||
|
||
onValues2Changed: {
|
||
if (values2.length < 4)
|
||
return;
|
||
|
||
const last = values2[values2.length - 1];
|
||
const prev = values2[values2.length - 2];
|
||
_pred2 = Math.max(minValue2, last + (last - prev));
|
||
|
||
if (!_ready2) {
|
||
_ready2 = true;
|
||
_t2 = 0;
|
||
} else {
|
||
_t2 = Math.max(0, _t2 - 1.0);
|
||
}
|
||
_animTimer.start();
|
||
}
|
||
|
||
Timer {
|
||
id: _animTimer
|
||
interval: 16
|
||
repeat: true
|
||
property real _prevTime: 0
|
||
|
||
onTriggered: {
|
||
const now = Date.now();
|
||
const elapsed = _prevTime > 0 ? (now - _prevTime) : 16;
|
||
_prevTime = now;
|
||
const dt = elapsed / root.updateInterval;
|
||
let stillAnimating = false;
|
||
|
||
// Scroll animation
|
||
if (root._t1 < 1.0) {
|
||
root._t1 = Math.min(1.0, root._t1 + dt);
|
||
stillAnimating = true;
|
||
}
|
||
if (root._t2 < 1.0) {
|
||
root._t2 = Math.min(1.0, root._t2 + dt);
|
||
stillAnimating = true;
|
||
}
|
||
|
||
// Scale animation (lerp toward target) - synchronized with scroll
|
||
if (root.animateScale) {
|
||
const scaleLerp = 0.15; // Smooth lerp factor per frame
|
||
const threshold = 0.5; // Snap when close enough
|
||
|
||
if (Math.abs(root._animMax1 - root._targetMax1) > threshold) {
|
||
root._animMax1 += (root._targetMax1 - root._animMax1) * scaleLerp;
|
||
stillAnimating = true;
|
||
} else if (root._animMax1 !== root._targetMax1) {
|
||
root._animMax1 = root._targetMax1;
|
||
}
|
||
|
||
if (Math.abs(root._animMax2 - root._targetMax2) > threshold) {
|
||
root._animMax2 += (root._targetMax2 - root._animMax2) * scaleLerp;
|
||
stillAnimating = true;
|
||
} else if (root._animMax2 !== root._targetMax2) {
|
||
root._animMax2 = root._targetMax2;
|
||
}
|
||
}
|
||
|
||
if (!stillAnimating) {
|
||
_prevTime = 0;
|
||
stop();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Convert a value to Y coordinate (with padding to keep values from touching edges)
|
||
// Clamps normalized range to [-10, 10] so coordinates stay within ~10× widget bounds,
|
||
// preventing extreme values that crash Qt's CurveRenderer triangulator.
|
||
function valueToY(val, minVal, maxVal) {
|
||
let range = maxVal - minVal;
|
||
if (range <= 0)
|
||
return height / 2;
|
||
let padding = range * curvePadding;
|
||
let paddedMin = minVal - padding;
|
||
let paddedMax = maxVal + padding;
|
||
let paddedRange = paddedMax - paddedMin;
|
||
let normalized = Math.max(-10, Math.min(10, (val - paddedMin) / paddedRange));
|
||
return height - normalized * height;
|
||
}
|
||
|
||
// Safe degenerate-path fallback: a valid off-screen line that renders nothing visible.
|
||
// "M 0 0" (bare moveto) crashes Qt's CurveRenderer — never use it.
|
||
readonly property string _safeFallbackPath: "M -1 -1 L -1 0"
|
||
|
||
// Build raw data points for both stroke and fill paths
|
||
function _buildRawPoints(vals, pred, minVal, maxVal, t) {
|
||
const n = vals.length;
|
||
const step = width / (n - 3);
|
||
if (!isFinite(step) || step <= 0)
|
||
return null;
|
||
|
||
let raw = [];
|
||
raw.push({
|
||
x: (-2 - t) * step,
|
||
y: valueToY(vals[0], minVal, maxVal)
|
||
});
|
||
raw.push({
|
||
x: (-1 - t) * step,
|
||
y: valueToY(vals[1], minVal, maxVal)
|
||
});
|
||
for (let i = 2; i < n; i++) {
|
||
raw.push({
|
||
x: (i - 2 - t) * step,
|
||
y: valueToY(vals[i], minVal, maxVal)
|
||
});
|
||
}
|
||
raw.push({
|
||
x: (n - 2 - t) * step,
|
||
y: valueToY(pred, minVal, maxVal)
|
||
});
|
||
|
||
// Validate all points — NaN/Infinity in any coordinate crashes CurveRenderer
|
||
for (let i = 0; i < raw.length; i++) {
|
||
if (!isFinite(raw[i].x) || !isFinite(raw[i].y))
|
||
return null;
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
// Build SVG stroke path with cubic bezier curves (Catmull-Rom → Bezier)
|
||
function buildStrokeSvg(vals, pred, minVal, maxVal, t) {
|
||
if (!vals || vals.length < 4 || width <= 0 || height <= 0)
|
||
return _safeFallbackPath;
|
||
|
||
const raw = _buildRawPoints(vals, pred, minVal, maxVal, t);
|
||
if (!raw)
|
||
return _safeFallbackPath;
|
||
|
||
let svg = `M ${raw[0].x} ${raw[0].y}`;
|
||
|
||
// Catmull-Rom to cubic bezier: cp1 = p1 + (p2-p0)/6, cp2 = p2 - (p3-p1)/6
|
||
for (let i = 0; i < raw.length - 1; i++) {
|
||
const p0 = raw[Math.max(i - 1, 0)];
|
||
const p1 = raw[i];
|
||
const p2 = raw[i + 1];
|
||
const p3 = raw[Math.min(i + 2, raw.length - 1)];
|
||
|
||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||
|
||
if (!isFinite(cp1x) || !isFinite(cp1y) || !isFinite(cp2x) || !isFinite(cp2y))
|
||
return _safeFallbackPath;
|
||
|
||
svg += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`;
|
||
}
|
||
|
||
return svg;
|
||
}
|
||
|
||
// Build SVG fill path with LINEAR segments only.
|
||
// CurveRenderer's processFill falls back to qTriangulate for complex curves,
|
||
// and Qt's ComplexToSimple::removeUnwantedEdgesAndConnect crashes on
|
||
// self-intersecting cubic bezier fill polygons. Linear segments produce a
|
||
// simple polygon that the triangulator handles safely. The smooth cubic
|
||
// stroke overlays this fill, so the linear edges are invisible.
|
||
function buildFillSvg(vals, pred, minVal, maxVal, t) {
|
||
if (!vals || vals.length < 4 || width <= 0 || height <= 0)
|
||
return _safeFallbackPath;
|
||
|
||
const raw = _buildRawPoints(vals, pred, minVal, maxVal, t);
|
||
if (!raw)
|
||
return _safeFallbackPath;
|
||
|
||
let svg = `M ${raw[0].x} ${raw[0].y}`;
|
||
for (let i = 1; i < raw.length; i++) {
|
||
svg += ` L ${raw[i].x} ${raw[i].y}`;
|
||
}
|
||
|
||
const last = raw[raw.length - 1];
|
||
svg += ` L ${last.x} ${height} L ${raw[0].x} ${height} Z`;
|
||
return svg;
|
||
}
|
||
|
||
// Reactive SVG paths — re-evaluated when any dependency changes
|
||
// Stroke uses smooth cubic bezier curves; fill uses linear segments to avoid
|
||
// crashing Qt's CurveRenderer triangulator (QTBUG: qTriangulate SEGV).
|
||
readonly property string _strokeSvg1: buildStrokeSvg(values, _pred1, minValue, _effectiveMax1, _t1)
|
||
readonly property string _fillSvg1: fill ? buildFillSvg(values, _pred1, minValue, _effectiveMax1, _t1) : _safeFallbackPath
|
||
readonly property string _strokeSvg2: buildStrokeSvg(values2, _pred2, minValue2, _effectiveMax2, _t2)
|
||
readonly property string _fillSvg2: fill ? buildFillSvg(values2, _pred2, minValue2, _effectiveMax2, _t2) : _safeFallbackPath
|
||
|
||
// Primary line — only rendered when there is enough data to form a valid path.
|
||
// Keeping primary and secondary in separate Shapes allows independent visibility
|
||
// gating, so neither ever receives a degenerate path from the other's dataset.
|
||
Shape {
|
||
anchors.fill: parent
|
||
preferredRendererType: Shape.CurveRenderer
|
||
visible: root.hasData && width > 0 && height > 0
|
||
|
||
ShapePath {
|
||
fillGradient: LinearGradient {
|
||
y1: 0
|
||
y2: root.height
|
||
|
||
GradientStop {
|
||
position: 0
|
||
color: Qt.rgba(root.color.r, root.color.g, root.color.b, root.fillOpacity)
|
||
}
|
||
|
||
GradientStop {
|
||
position: 1
|
||
color: "transparent"
|
||
}
|
||
}
|
||
strokeWidth: -1
|
||
strokeColor: "transparent"
|
||
|
||
PathSvg {
|
||
path: root._fillSvg1
|
||
}
|
||
}
|
||
|
||
ShapePath {
|
||
strokeColor: root.color
|
||
strokeWidth: root.strokeWidth
|
||
fillColor: "transparent"
|
||
capStyle: ShapePath.RoundCap
|
||
joinStyle: ShapePath.RoundJoin
|
||
|
||
PathSvg {
|
||
path: root._strokeSvg1
|
||
}
|
||
}
|
||
}
|
||
|
||
// Secondary line — only rendered when there is enough secondary data.
|
||
Shape {
|
||
anchors.fill: parent
|
||
preferredRendererType: Shape.CurveRenderer
|
||
visible: root.hasData2 && width > 0 && height > 0
|
||
|
||
ShapePath {
|
||
fillGradient: LinearGradient {
|
||
y1: 0
|
||
y2: root.height
|
||
|
||
GradientStop {
|
||
position: 0
|
||
color: Qt.rgba(root.color2.r, root.color2.g, root.color2.b, root.fillOpacity)
|
||
}
|
||
|
||
GradientStop {
|
||
position: 1
|
||
color: "transparent"
|
||
}
|
||
}
|
||
strokeWidth: -1
|
||
strokeColor: "transparent"
|
||
|
||
PathSvg {
|
||
path: root._fillSvg2
|
||
}
|
||
}
|
||
|
||
ShapePath {
|
||
strokeColor: root.color2
|
||
strokeWidth: root.strokeWidth
|
||
fillColor: "transparent"
|
||
capStyle: ShapePath.RoundCap
|
||
joinStyle: ShapePath.RoundJoin
|
||
|
||
PathSvg {
|
||
path: root._strokeSvg2
|
||
}
|
||
}
|
||
}
|
||
}
|