261 lines
9.9 KiB
QML
261 lines
9.9 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Controls.impl
|
||
import QtQuick.Layouts
|
||
import ISC
|
||
|
||
// Curve overlay chart card with the alignment timeline scrubber.
|
||
PanelBox {
|
||
id: card
|
||
|
||
required property var trendDataA
|
||
required property var trendDataB
|
||
required property int seriesLen
|
||
required property bool hasSeries
|
||
required property bool comparing
|
||
|
||
// Scrubber position — the internal Slider is private; consumers
|
||
// read/write frameIndex and call focusScrubber().
|
||
property alias frameIndex: scrubber.value
|
||
|
||
function focusScrubber() { scrubber.forceActiveFocus() }
|
||
|
||
implicitHeight: chartCol.implicitHeight + 24
|
||
|
||
ColumnLayout {
|
||
id: chartCol
|
||
anchors.fill: parent
|
||
anchors.margins: 12
|
||
spacing: 8
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
SectionTitle {
|
||
text: "CURVE OVERLAY"
|
||
Layout.fillWidth: true
|
||
}
|
||
Row {
|
||
spacing: 16
|
||
visible: card.hasSeries
|
||
Row {
|
||
spacing: 4
|
||
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
|
||
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||
|
||
}
|
||
Row {
|
||
spacing: 4
|
||
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
|
||
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 200
|
||
color: Theme.fieldBackground
|
||
border.color: Theme.cardBorder
|
||
border.width: 1
|
||
radius: Theme.radiusSm
|
||
clip: true
|
||
|
||
Canvas {
|
||
id: overlayCanvas
|
||
anchors.fill: parent
|
||
visible: card.hasSeries
|
||
|
||
readonly property real padLeft: 50
|
||
readonly property real padRight: 16
|
||
readonly property real padTop: 24
|
||
readonly property real padBottom: 36
|
||
|
||
// Nice tick step (1/2/5 × 10^k) — same scheme as TrendChartItem,
|
||
// so labels land on round numbers instead of raw data min/max.
|
||
function niceStep(rawStep) {
|
||
var mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
|
||
return [1, 2, 5, 10].map(function (s) { return s * mag })
|
||
.find(function (s) { return s >= rawStep })
|
||
}
|
||
|
||
function drawGrid(ctx, minV, maxV, yStep, dataLen) {
|
||
var plotW = width - padLeft - padRight
|
||
var plotH = height - padTop - padBottom
|
||
var range = (maxV - minV) || 1
|
||
|
||
ctx.strokeStyle = Theme.chartGridLine
|
||
ctx.lineWidth = 1
|
||
ctx.fillStyle = Theme.chartAxisText
|
||
ctx.font = "10px " + Theme.uiFontFamily
|
||
ctx.textAlign = "right"
|
||
|
||
var ySteps = Math.max(1, Math.round(range / yStep))
|
||
var yDec = Math.max(0, -Math.floor(Math.log10(yStep)))
|
||
for (var yi = 0; yi <= ySteps; yi++) {
|
||
var yFrac = yi / ySteps
|
||
var yPx = padTop + plotH * yFrac
|
||
var yVal = maxV - yFrac * range
|
||
|
||
ctx.beginPath()
|
||
ctx.moveTo(padLeft, yPx)
|
||
ctx.lineTo(padLeft + plotW, yPx)
|
||
ctx.stroke()
|
||
|
||
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
|
||
}
|
||
|
||
ctx.textAlign = "center"
|
||
var xMax = dataLen - 1
|
||
if (xMax > 0) {
|
||
var xStep = niceStep(xMax / 5 || 1)
|
||
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
|
||
var xPx = padLeft + (xIdx / xMax) * plotW
|
||
ctx.fillText(xIdx.toString(), xPx, height - 20)
|
||
}
|
||
}
|
||
|
||
ctx.save()
|
||
ctx.translate(14, padTop + plotH / 2)
|
||
ctx.rotate(-Math.PI / 2)
|
||
ctx.textAlign = "center"
|
||
ctx.font = "12px " + Theme.uiFontFamily
|
||
ctx.fillText("Celcius (°C)", 0, 0)
|
||
ctx.restore()
|
||
|
||
ctx.textAlign = "center"
|
||
ctx.font = "12px " + Theme.uiFontFamily
|
||
ctx.fillText("Frame", padLeft + plotW / 2, height - 4)
|
||
}
|
||
|
||
function drawSeries(ctx, series, minV, range, color, dataLen) {
|
||
// x is mapped against the shared frame domain (dataLen), not the
|
||
// series' own length — otherwise a shorter run's line stretches to
|
||
// fill the whole plot instead of stopping where its data ends.
|
||
if (series.length < 2) return
|
||
var plotW = width - padLeft - padRight
|
||
var plotH = height - padTop - padBottom
|
||
|
||
ctx.strokeStyle = color
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath()
|
||
for (var i = 0; i < series.length; i++) {
|
||
var x = padLeft + (i / (dataLen - 1)) * plotW
|
||
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
|
||
if (i === 0) ctx.moveTo(x, y)
|
||
else ctx.lineTo(x, y)
|
||
}
|
||
ctx.stroke()
|
||
}
|
||
|
||
function drawScrubMarker(ctx) {
|
||
if (card.seriesLen < 2) return
|
||
var plotW = width - padLeft - padRight
|
||
var x = padLeft + (scrubber.value / (card.seriesLen - 1)) * plotW
|
||
ctx.strokeStyle = Theme.primaryAccent
|
||
ctx.lineWidth = 1.5
|
||
ctx.setLineDash([3, 3])
|
||
ctx.beginPath()
|
||
ctx.moveTo(x, padTop)
|
||
ctx.lineTo(x, height - padBottom)
|
||
ctx.stroke()
|
||
ctx.setLineDash([])
|
||
}
|
||
|
||
onPaint: {
|
||
var ctx = getContext("2d")
|
||
ctx.reset()
|
||
if (!card.hasSeries) return
|
||
var all = card.trendDataA.concat(card.trendDataB)
|
||
var minV = Math.min.apply(null, all)
|
||
var maxV = Math.max.apply(null, all)
|
||
var padding = (maxV - minV) * 0.05
|
||
minV -= padding
|
||
maxV += padding
|
||
// Snap bounds to nice-step multiples -> stable round-number axis
|
||
var yStep = niceStep((maxV - minV) / 5 || 1)
|
||
minV = Math.floor(minV / yStep) * yStep
|
||
maxV = Math.ceil(maxV / yStep) * yStep
|
||
var range = (maxV - minV) || 1
|
||
|
||
drawGrid(ctx, minV, maxV, yStep, card.seriesLen)
|
||
drawSeries(ctx, card.trendDataA, minV, range, Theme.primaryAccent, card.seriesLen)
|
||
drawSeries(ctx, card.trendDataB, minV, range, Theme.themeSkill, card.seriesLen)
|
||
drawScrubMarker(ctx)
|
||
}
|
||
|
||
Connections {
|
||
target: card
|
||
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
|
||
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
|
||
}
|
||
Connections {
|
||
target: scrubber
|
||
function onValueChanged() { overlayCanvas.requestPaint() }
|
||
}
|
||
}
|
||
|
||
// Empty state
|
||
ColumnLayout {
|
||
anchors.centerIn: parent
|
||
visible: !card.hasSeries
|
||
spacing: 8
|
||
|
||
IconImage {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
visible: !card.comparing
|
||
source: "../icons/bar-chart.svg"
|
||
width: 32; height: 32
|
||
sourceSize.width: 32
|
||
sourceSize.height: 32
|
||
color: Theme.sideMutedText
|
||
}
|
||
Label {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
text: card.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
|
||
color: Theme.bodyColor
|
||
font.pixelSize: Theme.fontSm
|
||
}
|
||
Label {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
visible: !card.comparing
|
||
text: "Click a run box on the right, then select a file from the left panel."
|
||
color: Theme.sideMutedText
|
||
font.pixelSize: Theme.fontXs
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Alignment Timeline Scrubber ────────────
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 4
|
||
visible: card.hasSeries
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
height: 1
|
||
color: Theme.cardBorder
|
||
}
|
||
|
||
SectionTitle {
|
||
text: "TIMELINE SCRUBBER"
|
||
Layout.fillWidth: true
|
||
Layout.topMargin: 8
|
||
}
|
||
|
||
Slider {
|
||
id: scrubber
|
||
Layout.fillWidth: true
|
||
leftPadding: overlayCanvas.padLeft
|
||
rightPadding: overlayCanvas.padRight
|
||
from: 0
|
||
to: Math.max(1, card.seriesLen - 1)
|
||
stepSize: 1
|
||
value: 0
|
||
}
|
||
|
||
|
||
}
|
||
}
|
||
}
|