1099 lines
48 KiB
QML
1099 lines
48 KiB
QML
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|