import QtQuick import QtQuick.Controls import QtQuick.Controls.impl import QtQuick.Layouts import ISC import ISC.Wafer // ===== Data Tab (Compare Content Only) ===== // Per docs/design/navigation-mock.html: the Data tab shows only the Compare // Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts) // is intentionally not displayed in the application. // Layout: left column = aligned-curves chart with alignment scrubber + wafer // overlap map; right side panel = run selection dropdowns, overlap settings, // and DTW readout cards. Item { id: root anchors.fill: parent // Properties for DTW Comparison property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive property string compareFileA: "" property string compareFileB: "" property bool useMasterFile: false // Run A comes from settingsModel.masters[runBWaferType] instead of manual selection function waferTypeForFile(path) { if (!path) return "" // A file registered as a master carries that slot's family — this is // the only way a live recording gets a family (its filename/metadata // don't encode one reliably). for (var fam in settingsModel.masters) if (settingsModel.masters[fam] === path) return fam.toUpperCase() var rows = file_browser.files for (var i = 0; i < rows.length; i++) { if (rows[i].fileName === path) { if (rows[i].isRecording) return (rows[i].masterType || "").toUpperCase() return (rows[i].waferType || "").toUpperCase() } } var base = path.split("/").pop() if (base.toLowerCase().indexOf("live_") === 0) return "" return base ? base.charAt(0).toUpperCase() : "" } // Master file must match Run B's wafer family — looking it up by that // family is itself the comparability gate (a mismatched-family master // simply won't exist under this key). readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB) readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : "" readonly property string runAWaferType: root.useMasterFile ? root.runBWaferType : root.waferTypeForFile(root.compareFileA) // Family gate: both runs must resolve to the same wafer family. An empty // family means an unassigned live recording — comparable only after it is // registered as a master file (Settings → Master Files). readonly property string familyGateError: { var fileA = root.useMasterFile ? root.masterFileForRunB : root.compareFileA if (fileA === "" || root.compareFileB === "") return "" if (root.runAWaferType === "" || root.runBWaferType === "") return "Recording has no wafer family — register it as a master file (Settings → Master Files)" if (root.runAWaferType !== root.runBWaferType) return "Wafer family mismatch: " + root.runAWaferType + " vs " + root.runBWaferType return "" } property real warpingDistance: -1 property int frameOffset: 0 property real frameOffsetSeconds: 0 property real maxSensorDeviation: -1 property bool comparing: false property var trendDataA: [] property var trendDataB: [] property var timeDataA: [] property var timeDataB: [] property int frameCountA: 0 property int frameCountB: 0 property string errorMessage: "" property string mismatchMessage: "" // Wafer overlap properties property var compareSensorLayout: [] property var compareSensorDiff: [] property string compareWaferShape: "round" property real compareWaferSize: 300.0 readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1 readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length) function diffBands(diffs) { return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range") } function shortName(path) { return path ? path.split("/").pop() : "" } function scrubTimeLabel(idx) { var t = idx < root.timeDataA.length ? root.timeDataA[idx] : (idx < root.timeDataB.length ? root.timeDataB[idx] : null) return t !== null ? " (" + t.toFixed(1) + "s)" : "" } function formatMinutes(secondsValue) { var mins = Math.floor(secondsValue / 60) var secs = secondsValue % 60 return mins + ":" + (secs < 10 ? "0" : "") + secs.toFixed(1) } function frameForTime(t) { var times = root.timeDataA.length > 0 ? root.timeDataA : root.timeDataB if (times.length === 0) return 0 var closestIdx = 0 var minDiff = Math.abs(times[0] - t) for (var i = 1; i < times.length; i++) { var diff = Math.abs(times[i] - t) if (diff < minDiff) { minDiff = diff closestIdx = i } } return closestIdx } function clearResults() { root.warpingDistance = -1 root.frameOffset = 0 root.frameOffsetSeconds = 0 root.maxSensorDeviation = -1 root.trendDataA = [] root.trendDataB = [] root.timeDataA = [] root.timeDataB = [] root.frameCountA = 0 root.frameCountB = 0 root.compareSensorLayout = [] root.compareSensorDiff = [] root.errorMessage = "" root.mismatchMessage = "" mismatchHideTimer.stop() } function resetComparison() { root.compareFileA = "" root.compareFileB = "" root.useMasterFile = false root.comparing = false root.activeBox = 1 clearResults() } Component.onCompleted: file_browser.refreshFiles() Timer { id: mismatchHideTimer interval: 6000 onTriggered: root.mismatchMessage = "" } // Clicking a file in the left rail's browser populates the armed run slot Connections { target: streamController function onLoadedFileChanged() { if (streamController.loadedFile && root.activeBox > 0) { if (root.activeBox === 1) { root.compareFileA = streamController.loadedFile root.activeBox = 2 // Auto-advance to box 2 } else if (root.activeBox === 2) { root.compareFileB = streamController.loadedFile root.activeBox = 0 // Finished selecting } } } function onComparisonResult(result) { root.comparing = false if (result && result.success) { root.warpingDistance = result.distance root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0 root.frameOffsetSeconds = result.frame_offset_seconds !== undefined ? result.frame_offset_seconds : 0 root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1 root.trendDataA = result.series_a || [] root.trendDataB = result.series_b || [] root.timeDataA = result.time_a || [] root.timeDataB = result.time_b || [] root.frameCountA = result.frame_count_a || 0 root.frameCountB = result.frame_count_b || 0 root.compareSensorLayout = result.sensor_layout || [] root.compareSensorDiff = result.sensor_diff || [] root.compareWaferShape = result.wafer_shape || "round" root.compareWaferSize = result.wafer_size || 300.0 root.errorMessage = "" scrubber.value = Math.floor(root.seriesLen / 2) scrubber.forceActiveFocus() if (root.frameCountA !== root.frameCountB) { var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0 var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0 root.mismatchMessage = "Run A: " + root.frameCountA + " frames (" + timeA.toFixed(1) + "s) · " + "Run B: " + root.frameCountB + " frames (" + timeB.toFixed(1) + "s). " + "Deviation beyond the shorter run shows as \"none\"." mismatchHideTimer.restart() } else { root.mismatchMessage = "" mismatchHideTimer.stop() } } else { root.clearResults() root.errorMessage = (result && result.error) ? result.error : "Comparison failed" } } } // ── Reusable pieces ──────────────────────────────────────────── component SectionTitle: Label { font.family: Theme.uiFontFamily font.pixelSize: Theme.fontSm font.letterSpacing: 1.5 font.bold: true color: Theme.sideMutedText } component PanelBox: Rectangle { color: Theme.sidePanelBackground border.color: Theme.sideBorder border.width: 1 radius: Theme.sidePanelRadius } // Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent // indicator + check icon) instead of the platform-default CheckBox look. component PanelCheckBox: CheckBox { id: toggle indicator: Rectangle { implicitWidth: 18 implicitHeight: 18 x: toggle.leftPadding y: parent.height / 2 - height / 2 radius: Theme.radiusXs color: toggle.checked ? Theme.primaryAccent : "transparent" border.width: Theme.borderThin border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder Behavior on color { ColorAnimation { duration: Theme.durationFast } } Behavior on border.color { ColorAnimation { duration: Theme.durationFast } } IconImage { anchors.centerIn: parent source: "icons/check.svg" width: 12; height: 12 sourceSize.width: 12 sourceSize.height: 12 color: Theme.panelBackground opacity: toggle.checked ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Theme.durationFast } } } } contentItem: Text { text: toggle.text color: Theme.headingColor font.family: Theme.uiFontFamily verticalAlignment: Text.AlignVCenter leftPadding: toggle.indicator.width + toggle.spacing font.pixelSize: toggle.font.pixelSize || Theme.fontXs } } // Slider styled to match MapTab's right-rail heatmap slider (circular // accent handle that grows slightly on press). component PanelSlider: Slider { id: panelSlider handle: Rectangle { x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width) y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2 implicitWidth: 18 implicitHeight: 18 radius: 9 color: Theme.primaryAccent scale: panelSlider.pressed ? 1.15 : 1.0 Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } } } } // Mini stat box under the alignment scrubber (styled to match DTW ReadoutCard) component ScrubStat: Rectangle { id: scrubStat property string label property string value property color valueColor: Theme.headingColor Layout.fillWidth: true implicitHeight: 52 radius: Theme.radiusSm color: Theme.subtleSectionBackground border.color: Theme.cardBorder border.width: 1 ColumnLayout { anchors.fill: parent anchors.margins: 8 spacing: 2 Label { text: scrubStat.label.toUpperCase() font.pixelSize: Theme.fontXs font.bold: true font.letterSpacing: 0.2 color: Theme.sideMutedText } Label { text: scrubStat.value font.pixelSize: Theme.fontLg font.bold: true font.family: Theme.codeFontFamily color: scrubStat.valueColor } } } // Editable stat box for scrubber readout (styled to match DTW ReadoutCard) component EditableScrubStat: Rectangle { id: edStat property string label property real value property string unit property string extraText: "" property bool editable: true signal valueEdited(real newValue) Layout.fillWidth: true implicitHeight: edStatRow.implicitHeight + 16 radius: Theme.radiusSm color: Theme.subtleSectionBackground border.color: (edStat.editable && inputField.activeFocus) ? Theme.fieldBorderFocus : Theme.cardBorder border.width: (edStat.editable && inputField.activeFocus) ? Theme.borderStrong : Theme.borderThin RowLayout { id: edStatRow anchors.fill: parent anchors.margins: 8 spacing: 6 ColumnLayout { Layout.fillWidth: true spacing: 2 Label { text: edStat.label.toUpperCase() font.pixelSize: Theme.fontXs font.bold: true font.letterSpacing: 0.2 color: Theme.sideMutedText } RowLayout { spacing: 4 TextField { id: inputField readOnly: !edStat.editable hoverEnabled: edStat.editable font.pixelSize: Theme.fontLg font.bold: true font.family: Theme.codeFontFamily color: edStat.editable ? Theme.headingColor : Theme.sideMutedText verticalAlignment: TextInput.AlignVCenter horizontalAlignment: TextInput.AlignHCenter background: Rectangle { visible: edStat.editable color: Theme.fieldBackground radius: Theme.radiusXs border.color: inputField.activeFocus ? Theme.fieldBorderFocus : (inputField.hovered ? Theme.fieldBorderFocus : Theme.fieldBorder) border.width: 1 } padding: 0 leftPadding: edStat.editable ? 6 : 0 rightPadding: edStat.editable ? 6 : 0 topPadding: edStat.editable ? 5 : 0 bottomPadding: edStat.editable ? 5 : 0 selectByMouse: edStat.editable inputMethodHints: edStat.label === "Time" ? Qt.ImhFormattedNumbersOnly : Qt.ImhDigitsOnly Layout.preferredWidth: edStat.editable ? Math.max(36, contentWidth + 12) : contentWidth Layout.preferredHeight: edStat.editable ? 32 : implicitHeight text: edStat.value.toFixed(edStat.label === "Time" ? 1 : 0) onEditingFinished: { var val = parseFloat(text) if (!isNaN(val)) { edStat.valueEdited(val) } text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0) scrubber.forceActiveFocus() } Connections { target: edStat function onValueChanged() { if (!inputField.activeFocus) { inputField.text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0) } } } } Label { text: edStat.unit font.pixelSize: Theme.fontLg font.bold: true font.family: Theme.codeFontFamily color: edStat.editable ? Theme.headingColor : Theme.sideMutedText Layout.alignment: Qt.AlignVCenter } } } Label { visible: edStat.extraText !== "" text: edStat.extraText font.pixelSize: Theme.fontLg font.bold: true font.family: Theme.codeFontFamily color: Theme.headingColor Layout.alignment: Qt.AlignVCenter } } } // Large labeled value card for the DTW readout panel component ReadoutCard: Rectangle { id: readoutCard property string label property string value property color valueColor: Theme.headingColor Layout.fillWidth: true implicitHeight: 52 radius: Theme.radiusSm color: Theme.subtleSectionBackground border.color: Theme.cardBorder border.width: 1 ColumnLayout { anchors.fill: parent anchors.margins: 8 spacing: 2 Label { text: readoutCard.label font.pixelSize: Theme.fontXs font.bold: true font.letterSpacing: 0.2 color: Theme.sideMutedText } Label { text: readoutCard.value font.pixelSize: Theme.fontLg font.bold: true font.family: Theme.codeFontFamily color: readoutCard.valueColor } } } // ── Main Layout ─────────────────────────────────────────────── ColumnLayout { anchors.fill: parent anchors.margins: Theme.panelPadding spacing: Theme.rightPaneGap // ── Error Banner ─────────────────────────────────────────── Rectangle { Layout.fillWidth: true Layout.preferredHeight: errorRow.implicitHeight + 16 visible: root.errorMessage !== "" color: Theme.errorSurface border.color: Theme.statusErrorColor border.width: 1 radius: Theme.radiusSm RowLayout { id: errorRow anchors.fill: parent anchors.margins: 8 spacing: 8 IconImage { source: "icons/triangle-alert.svg" width: 16; height: 16 sourceSize.width: 16 sourceSize.height: 16 color: Theme.errorTextSoft Layout.alignment: Qt.AlignTop } Label { id: errorLabel text: root.errorMessage color: Theme.errorTextSoft font.pixelSize: Theme.fontSm wrapMode: Text.WordWrap Layout.fillWidth: true } } } // ── Body: main column | side panel ───────────────────────── RowLayout { Layout.fillWidth: true Layout.fillHeight: true spacing: 16 // --- Left Column: chart + scrubber, wafer overlap map --- ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true Layout.minimumWidth: 350 spacing: 12 // Wafer Map Overlap View card PanelBox { Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 220 ColumnLayout { anchors.fill: parent anchors.margins: 12 spacing: 8 RowLayout { Layout.fillWidth: true SectionTitle { text: "WAFER OVERLAP" Layout.fillWidth: true } } Rectangle { Layout.fillWidth: true Layout.fillHeight: true color: Theme.fieldBackground border.color: Theme.cardBorder border.width: 1 radius: Theme.radiusSm clip: true WaferMapItem { id: overlapMapItem anchors.fill: parent visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked sensors: root.compareSensorLayout values: streamController.getSensorDiffAt(scrubReadout.scrubIdx) bands: root.diffBands(overlapMapItem.values) shape: root.compareWaferShape size: root.compareWaferSize target: 0 margin: 1.0 blend: blendSlider.value / 100 showLabels: true ringColor: Theme.waferRingColor axisColor: Theme.waferAxisColor lowColor: Theme.sensorLow inRangeColor: Theme.sensorInRange highColor: Theme.sensorHigh textColor: Theme.headingColor } ColumnLayout { anchors.centerIn: parent visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked spacing: 4 IconImage { Layout.alignment: Qt.AlignHCenter source: "icons/map.svg" width: 24; height: 24 sourceSize.width: 24 sourceSize.height: 24 color: Theme.sideMutedText } Label { Layout.alignment: Qt.AlignHCenter text: !enableOverlapCheck.checked ? "Overlap map disabled" : "No sensor map available" color: Theme.bodyColor font.pixelSize: Theme.fontXs } } } } } // Chart card with alignment scrubber PanelBox { Layout.fillWidth: true Layout.preferredHeight: 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: root.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: root.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 (root.seriesLen < 2) return var plotW = width - padLeft - padRight var x = padLeft + (scrubber.value / (root.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 (!root.hasSeries) return var all = root.trendDataA.concat(root.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, root.seriesLen) drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen) drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen) drawScrubMarker(ctx) } Connections { target: root function onTrendDataAChanged() { overlayCanvas.requestPaint() } function onTrendDataBChanged() { overlayCanvas.requestPaint() } } Connections { target: scrubber function onValueChanged() { overlayCanvas.requestPaint() } } } // Empty state ColumnLayout { anchors.centerIn: parent visible: !root.hasSeries spacing: 8 IconImage { Layout.alignment: Qt.AlignHCenter visible: !root.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: root.comparing ? "Running DTW comparison…" : "Select two runs and run comparison" color: Theme.bodyColor font.pixelSize: Theme.fontSm } Label { Layout.alignment: Qt.AlignHCenter visible: !root.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: root.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, root.seriesLen - 1) stepSize: 1 value: 0 } } } } } // --- Right Column: run selection, overlap settings, readout --- ColumnLayout { Layout.fillWidth: false Layout.preferredWidth: 280 Layout.minimumWidth: 280 Layout.maximumWidth: 280 Layout.fillHeight: true spacing: 12 // Bento 3: DTW Comparison Readout PanelBox { Layout.fillWidth: true Layout.preferredHeight: readoutCol.implicitHeight + 24 ColumnLayout { id: readoutCol anchors.fill: parent anchors.margins: 12 spacing: 8 SectionTitle { text: "DTW READOUT" } ReadoutCard { label: "DTW ALIGNMENT DISTANCE" value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--" valueColor: root.warpingDistance >= 0 ? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad) : Theme.sideMutedText } ReadoutCard { label: "TEMPORAL FRAME OFFSET" value: root.warpingDistance >= 0 ? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / " + (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s") : "--" valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText } ReadoutCard { label: "MAX SENSOR DEVIATION" value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--" valueColor: root.maxSensorDeviation >= 0 ? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad) : Theme.sideMutedText } } } // Bento 2: Overlap Map Settings PanelBox { Layout.fillWidth: true Layout.preferredHeight: overlapCol.implicitHeight + 24 ColumnLayout { id: overlapCol anchors.fill: parent anchors.margins: 12 spacing: 8 SectionTitle { text: "OVERLAP SETTINGS" } PanelCheckBox { id: enableOverlapCheck Layout.fillWidth: true checked: true text: "Enable Overlap Map" font.pixelSize: Theme.fontSm } ColumnLayout { Layout.fillWidth: true spacing: 6 visible: enableOverlapCheck.checked Rectangle { Layout.fillWidth: true height: 1 color: Theme.cardBorder } RowLayout { Layout.fillWidth: true spacing: 6 Label { text: "Blend" font.pixelSize: Theme.fontSm color: Theme.bodyColor Layout.preferredWidth: 40 } PanelSlider { id: blendSlider Layout.fillWidth: true from: 0; to: 100; value: 50 stepSize: 1 } Label { text: Math.round(blendSlider.value) + "%" font.pixelSize: Theme.fontSm color: Theme.sideMutedText Layout.preferredWidth: 30 } } // Diff color legend Row { Layout.fillWidth: true spacing: 10 Row { spacing: 3 Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter } Label { text: "B cooler"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText } } Row { spacing: 3 Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter } Label { text: "Match"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText } } Row { spacing: 3 Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter } Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText } } } } } } // Bento: Frame / Time Controls PanelBox { Layout.fillWidth: true Layout.preferredHeight: frameTimeCol.implicitHeight + 32 ColumnLayout { id: frameTimeCol anchors.fill: parent anchors.margins: 16 spacing: 20 SectionTitle { text: "FRAME / TIME" } EditableScrubStat { label: "Frame" value: scrubReadout.scrubIdx unit: "/ " + Math.max(0, root.seriesLen - 1) + (Math.max(0, root.seriesLen - 1) <= 1 ? " frame" : " frames") editable: root.hasSeries onValueEdited: (newValue) => { var maxVal = Math.max(0, root.seriesLen - 1) var val = Math.max(0, Math.min(newValue, maxVal)) scrubber.value = val } } EditableScrubStat { label: "Time" value: scrubReadout.scrubTime unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s" editable: root.hasSeries onValueEdited: (newValue) => { var closestIdx = root.frameForTime(newValue) scrubber.value = closestIdx } } } } // Bento: Scrub Readout PanelBox { Layout.fillWidth: true Layout.preferredHeight: scrubReadout.implicitHeight + 24 ColumnLayout { id: scrubReadout anchors.fill: parent anchors.margins: 12 spacing: 8 readonly property int scrubIdx: Math.round(scrubber.value) readonly property bool hasA: scrubIdx < root.trendDataA.length readonly property bool hasB: scrubIdx < root.trendDataB.length readonly property real tempA: hasA ? root.trendDataA[scrubIdx] : 0 readonly property real tempB: hasB ? root.trendDataB[scrubIdx] : 0 readonly property real scrubTime: { var t = scrubIdx < root.timeDataA.length ? root.timeDataA[scrubIdx] : (scrubIdx < root.timeDataB.length ? root.timeDataB[scrubIdx] : 0) return t } readonly property real maxTime: { var ta = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0 var tb = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0 return Math.max(ta, tb) } SectionTitle { text: "SCRUBBER READOUT" } ScrubStat { label: "Temp (Run A)" value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none" valueColor: Theme.primaryAccent } ScrubStat { label: "Temp (Run B)" value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none" valueColor: Theme.themeSkill } ScrubStat { label: "Difference" value: (scrubReadout.hasA && scrubReadout.hasB) ? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none" } } } Item { Layout.fillHeight: true } } } } // ── Frame Mismatch Banner (amber, floats over content, auto-hides) ── Rectangle { id: mismatchBanner visible: root.mismatchMessage !== "" z: 100 anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter anchors.topMargin: 12 width: Math.min(parent.width - 48, 640) height: mismatchRow.implicitHeight + 16 color: Theme.cardBackground border.color: Theme.statusWarningColor border.width: 1 radius: Theme.radiusSm RowLayout { id: mismatchRow anchors.fill: parent anchors.margins: 8 spacing: 8 IconImage { source: "icons/triangle-alert.svg" width: 16; height: 16 sourceSize.width: 16 sourceSize.height: 16 color: Theme.statusWarningColor Layout.alignment: Qt.AlignTop } Label { text: root.mismatchMessage color: Theme.statusWarningColor font.pixelSize: Theme.fontSm wrapMode: Text.WordWrap Layout.fillWidth: true } Button { flat: true implicitWidth: 24 implicitHeight: 24 Layout.alignment: Qt.AlignTop onClicked: { root.mismatchMessage = "" mismatchHideTimer.stop() } background: Rectangle { color: "transparent" } contentItem: Label { text: "✕" color: Theme.statusWarningColor font.pixelSize: Theme.fontXs horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } } } }