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 } } } }