feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab
This commit is contained in:
+287
-7
@@ -181,8 +181,6 @@ Rectangle {
|
||||
Layout.preferredWidth: Theme.sideRailWidth
|
||||
Layout.fillHeight: true
|
||||
color: Theme.sideRailBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
@@ -290,6 +288,286 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
|
||||
Rectangle {
|
||||
id: runComparisonBox
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: runCompCol.implicitHeight + 28
|
||||
visible: root.selectedTabIndex === 1
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
readonly property var dataTab: dataTabLoader.item
|
||||
|
||||
component RunSlot: Rectangle {
|
||||
id: slotBox
|
||||
property string title
|
||||
property string filePath: ""
|
||||
property color accent: Theme.primaryAccent
|
||||
property bool active: false
|
||||
property bool locked: false
|
||||
property string placeholderText: "Click to select"
|
||||
signal cleared()
|
||||
signal armed()
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 58
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.fieldBackground
|
||||
opacity: slotBox.locked ? 0.65 : 1.0
|
||||
border.width: slotBox.active ? 2 : 1
|
||||
border.color: slotBox.active ? accent : Theme.sideBorder
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !slotBox.locked
|
||||
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: slotBox.armed()
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: slotBox.accent
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: slotBox.title
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
text: slotBox.filePath !== ""
|
||||
? slotBox.filePath.split("/").pop()
|
||||
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: slotBox.filePath !== "" && !slotBox.locked
|
||||
flat: true
|
||||
implicitWidth: 24; implicitHeight: 24
|
||||
onClicked: slotBox.cleared()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "Tabs/icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: "Tabs/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
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: runCompCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: "RUN COMPARISON (DTW)"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Button {
|
||||
id: resetRunBtn
|
||||
flat: true
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
visible: true
|
||||
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "Tabs/icons/rotate-ccw.svg"
|
||||
width: 18; height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
ToolTip.visible: resetRunBtn.hovered
|
||||
ToolTip.text: "Reset comparison"
|
||||
}
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: useMasterCheck
|
||||
Layout.fillWidth: true
|
||||
text: "Compare vs Master File"
|
||||
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
onToggled: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.useMasterFile = checked
|
||||
if (checked) runComparisonBox.dataTab.activeBox = 2
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
|
||||
accent: Theme.primaryAccent
|
||||
locked: useMaster
|
||||
placeholderText: useMaster
|
||||
? (runComparisonBox.dataTab.runBWaferType !== ""
|
||||
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
|
||||
: "Select Run B to resolve master")
|
||||
: "Click to select"
|
||||
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
|
||||
filePath: runComparisonBox.dataTab
|
||||
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
|
||||
: ""
|
||||
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileA = ""
|
||||
runComparisonBox.dataTab.activeBox = 1
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
title: "SESSION (RUN B)"
|
||||
accent: Theme.themeSkill
|
||||
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
|
||||
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
|
||||
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileB = ""
|
||||
runComparisonBox.dataTab.activeBox = 2
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: compareRunBtn
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
readonly property var dt: runComparisonBox.dataTab
|
||||
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
||||
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
||||
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
||||
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing
|
||||
ToolTip.visible: (sameFile || masterMissing) && hovered
|
||||
ToolTip.text: masterMissing
|
||||
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||
: "Choose two different runs to compare"
|
||||
onClicked: {
|
||||
dt.comparing = true
|
||||
dt.clearResults()
|
||||
streamController.compareFiles(fileA, dt.compareFileB)
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: compareRunBtn.enabled
|
||||
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||
: Theme.buttonNeutralBackground
|
||||
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 8
|
||||
anchors.centerIn: parent
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: !compareRunBtn.dt ? "Select both runs"
|
||||
: compareRunBtn.dt.comparing
|
||||
? "Comparing…"
|
||||
: compareRunBtn.enabled
|
||||
? "Run DTW Comparison"
|
||||
: (compareRunBtn.masterMissing ? "No master for this type"
|
||||
: compareRunBtn.sameFile ? "Choose different runs" : "Select both runs")
|
||||
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
BusyIndicator {
|
||||
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
width: 14; height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
@@ -363,20 +641,23 @@ Rectangle {
|
||||
}
|
||||
|
||||
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||
// Not relevant on the Data tab — Run Comparison takes its place above.
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.sideFooterConnection
|
||||
visible: root.selectedTabIndex !== 1
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
// Row 1: Title + status badge (unchanged)
|
||||
// Row 1: Title + status badge
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
Layout.fillWidth: true
|
||||
@@ -388,12 +669,15 @@ Rectangle {
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 18
|
||||
width: badgeLabel.implicitWidth + 14
|
||||
radius: 9
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: {
|
||||
var s = deviceController.connectionStatus
|
||||
if (s === "Connected") return Theme.statusSuccessColor
|
||||
@@ -410,8 +694,6 @@ Rectangle {
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
// Row 2: Status dot + descriptive text (replaces dotted trail)
|
||||
@@ -595,8 +877,6 @@ Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.workspaceBackground
|
||||
border.color: Theme.workspaceBorder
|
||||
border.width: 1
|
||||
|
||||
StackLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
+331
-336
@@ -20,6 +20,23 @@ Item {
|
||||
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 ""
|
||||
var rows = file_browser.files
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].fileName === path) return (rows[i].waferType || "").toUpperCase()
|
||||
}
|
||||
var base = path.split("/").pop()
|
||||
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] || "") : ""
|
||||
property real warpingDistance: -1
|
||||
property int frameOffset: 0
|
||||
property real frameOffsetSeconds: 0
|
||||
@@ -57,6 +74,27 @@ Item {
|
||||
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
|
||||
@@ -78,6 +116,7 @@ Item {
|
||||
function resetComparison() {
|
||||
root.compareFileA = ""
|
||||
root.compareFileB = ""
|
||||
root.useMasterFile = false
|
||||
root.comparing = false
|
||||
root.activeBox = 1
|
||||
clearResults()
|
||||
@@ -124,6 +163,7 @@ Item {
|
||||
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
|
||||
@@ -213,34 +253,33 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Mini stat box under the alignment scrubber (mockup's subtle-box trio)
|
||||
// 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: 38
|
||||
radius: 4
|
||||
color: Theme.cardWash
|
||||
implicitHeight: 52
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.subtleSectionBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.topMargin: 4
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 0
|
||||
anchors.margins: 8
|
||||
spacing: 2
|
||||
Label {
|
||||
text: scrubStat.label
|
||||
font.pixelSize: 9
|
||||
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.fontSm
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
font.family: Theme.codeFontFamily
|
||||
color: scrubStat.valueColor
|
||||
@@ -248,6 +287,111 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -282,128 +426,13 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Run slot card: click to arm, then pick a file in the left rail's browser
|
||||
component RunSlot: Rectangle {
|
||||
id: slotBox
|
||||
property int boxIndex: 1
|
||||
property string title
|
||||
property string filePath: ""
|
||||
property color accent: Theme.primaryAccent
|
||||
signal cleared()
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 58
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.fieldBackground
|
||||
border.width: root.activeBox === boxIndex ? 2 : 1
|
||||
border.color: root.activeBox === boxIndex ? accent : Theme.cardBorder
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activeBox = slotBox.boxIndex
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: slotBox.accent
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: slotBox.title
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
text: slotBox.filePath !== ""
|
||||
? root.shortName(slotBox.filePath)
|
||||
: (root.activeBox === slotBox.boxIndex ? "<- Select file from left panel..." : "Click to select")
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: slotBox.filePath !== ""
|
||||
flat: true
|
||||
implicitWidth: 24; implicitHeight: 24
|
||||
onClicked: slotBox.cleared()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Layout ───────────────────────────────────────────────
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
// ── Title / Header ─────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: "Run Comparison (DTW)"
|
||||
font.pixelSize: Theme.fontXl
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Button {
|
||||
id: resetBtn
|
||||
flat: true
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
visible: root.compareFileA !== "" || root.compareFileB !== ""
|
||||
onClicked: root.resetComparison()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: resetBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "icons/rotate-ccw.svg"
|
||||
width: 18; height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
ToolTip.visible: resetBtn.hovered
|
||||
ToolTip.text: "Reset comparison"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error Banner ───────────────────────────────────────────
|
||||
Rectangle {
|
||||
@@ -454,6 +483,79 @@ Item {
|
||||
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
|
||||
@@ -478,6 +580,7 @@ Item {
|
||||
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
|
||||
@@ -668,129 +771,19 @@ Item {
|
||||
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
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: scrubReadout
|
||||
Layout.fillWidth: true
|
||||
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
|
||||
|
||||
ScrubStat {
|
||||
label: "Position"
|
||||
value: scrubReadout.scrubIdx + " / " + Math.max(0, root.seriesLen - 1)
|
||||
+ root.scrubTimeLabel(scrubReadout.scrubIdx)
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Label {
|
||||
visible: root.compareFileA !== "" && root.compareFileB !== ""
|
||||
text: (root.shortName(root.compareFileA).replace(".csv", "") + " vs "
|
||||
+ root.shortName(root.compareFileB).replace(".csv", "")).toUpperCase()
|
||||
font.pixelSize: Theme.fontXs
|
||||
color: Theme.sideMutedText
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
|
||||
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: root.compareSensorDiff
|
||||
bands: root.diffBands(root.compareSensorDiff)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Right Column: run selection, overlap settings, readout ---
|
||||
ColumnLayout {
|
||||
@@ -801,85 +794,40 @@ Item {
|
||||
Layout.fillHeight: true
|
||||
spacing: 12
|
||||
|
||||
// Bento 1: Run Selection
|
||||
// Bento 3: DTW Comparison Readout
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: runSelCol.implicitHeight + 24
|
||||
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: runSelCol
|
||||
id: readoutCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 10
|
||||
|
||||
SectionTitle { text: "RUN SELECTION" }
|
||||
|
||||
RunSlot {
|
||||
title: "REFERENCE (RUN A)"
|
||||
boxIndex: 1
|
||||
accent: Theme.primaryAccent
|
||||
filePath: root.compareFileA
|
||||
onCleared: {
|
||||
root.compareFileA = ""
|
||||
root.activeBox = 1
|
||||
root.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
title: "SESSION (RUN B)"
|
||||
boxIndex: 2
|
||||
accent: Theme.themeSkill
|
||||
filePath: root.compareFileB
|
||||
onCleared: {
|
||||
root.compareFileB = ""
|
||||
root.activeBox = 2
|
||||
root.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: compareBtn
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
readonly property bool sameFile: root.compareFileA !== "" && root.compareFileA === root.compareFileB
|
||||
enabled: root.compareFileA !== "" && root.compareFileB !== "" && !sameFile && !root.comparing
|
||||
ToolTip.visible: sameFile && hovered
|
||||
ToolTip.text: "Choose two different runs to compare"
|
||||
onClicked: {
|
||||
root.comparing = true
|
||||
root.clearResults()
|
||||
streamController.compareFiles(root.compareFileA, root.compareFileB)
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: compareBtn.enabled
|
||||
? (compareBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||
: Theme.buttonNeutralBackground
|
||||
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 8
|
||||
anchors.centerIn: parent
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.comparing
|
||||
? "Comparing…"
|
||||
: compareBtn.enabled
|
||||
? "Run DTW Comparison"
|
||||
: (compareBtn.sameFile ? "Choose different runs" : "Select both runs")
|
||||
color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
BusyIndicator {
|
||||
running: root.comparing
|
||||
visible: root.comparing
|
||||
width: 14; height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -946,57 +894,104 @@ Item {
|
||||
Row {
|
||||
spacing: 3
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
|
||||
Label { text: "B cooler"; font.pixelSize: Theme.fontXs; color: Theme.sideMutedText }
|
||||
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.fontXs; color: Theme.sideMutedText }
|
||||
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.fontXs; color: Theme.sideMutedText }
|
||||
Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bento 3: DTW Comparison Readout
|
||||
// Bento: Frame / Time Controls
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||
Layout.preferredHeight: frameTimeCol.implicitHeight + 32
|
||||
|
||||
ColumnLayout {
|
||||
id: readoutCol
|
||||
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
|
||||
|
||||
SectionTitle { text: "DTW READOUT" }
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
SectionTitle { text: "SCRUBBER READOUT" }
|
||||
|
||||
ScrubStat {
|
||||
label: "Temp (Run A)"
|
||||
value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
|
||||
valueColor: Theme.primaryAccent
|
||||
}
|
||||
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
|
||||
ScrubStat {
|
||||
label: "Temp (Run B)"
|
||||
value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
|
||||
valueColor: Theme.themeSkill
|
||||
}
|
||||
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
|
||||
ScrubStat {
|
||||
label: "Difference"
|
||||
value: (scrubReadout.hasA && scrubReadout.hasB)
|
||||
? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ Item {
|
||||
}
|
||||
TabButton {
|
||||
text: "Review"
|
||||
implicitWidth: 64
|
||||
implicitHeight: 28
|
||||
implicitWidth: 84
|
||||
implicitHeight: 34
|
||||
background: Rectangle {
|
||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||
radius: Theme.radiusSm - 1
|
||||
@@ -83,29 +83,61 @@ Item {
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
TabButton {
|
||||
id: liveTab
|
||||
text: "Live"
|
||||
enabled: deviceController.connectionStatus === "Connected"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
implicitWidth: 58
|
||||
implicitHeight: 28
|
||||
implicitWidth: 84
|
||||
implicitHeight: 34
|
||||
// Disabled controls swallow no events at all, so hover for the
|
||||
// "why is this greyed out" tooltip needs its own MouseArea.
|
||||
ToolTip.visible: disabledHover.containsMouse && !enabled
|
||||
ToolTip.text: "Connect a wafer to enable Live mode"
|
||||
ToolTip.delay: 300
|
||||
MouseArea {
|
||||
id: disabledHover
|
||||
anchors.fill: parent
|
||||
enabled: !liveTab.enabled
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
background: Rectangle {
|
||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||
radius: Theme.radiusSm - 1
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
contentItem: Row {
|
||||
spacing: 5
|
||||
leftPadding: 8
|
||||
Rectangle {
|
||||
id: liveTabDot
|
||||
width: 7; height: 7; radius: 3.5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
||||
|
||||
// Flash while actually streaming live
|
||||
SequentialAnimation on opacity {
|
||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
||||
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
||||
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: liveTab.text
|
||||
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: liveTab.checked ? Font.Medium : Font.Normal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
height: parent.height
|
||||
}
|
||||
}
|
||||
}
|
||||
onCurrentIndexChanged: if (currentIndex === 1) {
|
||||
@@ -127,44 +159,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Live source info: WiFi icon + port · serial
|
||||
RowLayout {
|
||||
visible: streamController.mode === "live"
|
||||
spacing: 5
|
||||
// Live status dot (matches the connection-flow indicator dot elsewhere)
|
||||
Rectangle {
|
||||
id: liveIndicator
|
||||
width: 8; height: 8
|
||||
radius: 4
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation {
|
||||
to: 0.2
|
||||
duration: 600
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
NumberAnimation {
|
||||
to: 1.0
|
||||
duration: 600
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
onRunningChanged: {
|
||||
if (!running)
|
||||
liveIndicator.opacity = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: "Live stream"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
@@ -190,14 +190,35 @@ ColumnLayout {
|
||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||
}
|
||||
|
||||
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
|
||||
readonly property bool isActive:{
|
||||
readonly property var dataTab: {
|
||||
try {
|
||||
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
|
||||
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
|
||||
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
} catch (e) {}
|
||||
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
||||
|
||||
// Run A's effective file: the master file when "Compare vs Master
|
||||
// File" is toggled on, otherwise the manually selected file.
|
||||
readonly property string effectiveRunAFile: fileItem.dataTab
|
||||
? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA)
|
||||
: ""
|
||||
readonly property bool isRunA: fileItem.dataTab !== null
|
||||
&& fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile
|
||||
readonly property bool isRunB: fileItem.dataTab !== null
|
||||
&& modelData.fileName === fileItem.dataTab.compareFileB
|
||||
|
||||
// Active highlight logic: on Data tab, highlight the Run A/Run B files (or the
|
||||
// resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab).
|
||||
readonly property bool isActive: fileItem.dataTab !== null
|
||||
? (fileItem.isRunA || fileItem.isRunB)
|
||||
: (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile)
|
||||
|
||||
// A file registered as any family's master in Settings → Master Files.
|
||||
readonly property bool isMasterFile: {
|
||||
var m = settingsModel.masters
|
||||
for (var fam in m) {
|
||||
if (m[fam] && m[fam] === modelData.fileName) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
|
||||
@@ -233,6 +254,8 @@ ColumnLayout {
|
||||
bottomMargin: 5
|
||||
}
|
||||
color: {
|
||||
if (fileItem.isRunA) return Theme.primaryAccent;
|
||||
if (fileItem.isRunB) return Theme.themeSkill;
|
||||
var t = modelData.waferType;
|
||||
if (t === "A" || t === "E" || t === "P")
|
||||
return Theme.familyBlueAccent;
|
||||
@@ -290,6 +313,16 @@ ColumnLayout {
|
||||
anchors.topMargin: -2
|
||||
anchors.rightMargin: -2
|
||||
}
|
||||
|
||||
// Accent ring — this file is registered as a master (Settings → Master Files).
|
||||
Rectangle {
|
||||
visible: fileItem.isMasterFile
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.color: Theme.primaryAccent
|
||||
border.width: 2
|
||||
}
|
||||
}
|
||||
|
||||
// Typography hierarchy
|
||||
@@ -329,6 +362,25 @@ ColumnLayout {
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: fileItem.isMasterFile
|
||||
radius: 4
|
||||
color: Qt.alpha(Theme.primaryAccent, 0.18)
|
||||
implicitWidth: masterLabel.implicitWidth + 10
|
||||
implicitHeight: masterLabel.implicitHeight + 4
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Label {
|
||||
id: masterLabel
|
||||
anchors.centerIn: parent
|
||||
text: "MASTER"
|
||||
color: Theme.primaryAccent
|
||||
font.pixelSize: Theme.font2xs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: date · time
|
||||
|
||||
@@ -205,7 +205,7 @@ QtObject {
|
||||
readonly property int rightPaneGap: 6
|
||||
readonly property int panelPadding: 12
|
||||
// Side rail layout
|
||||
readonly property int sideRailWidth: 280
|
||||
readonly property int sideRailWidth: 300
|
||||
readonly property int sideRailMargin: 16
|
||||
readonly property int sideRailSpacing: 16
|
||||
readonly property int sideButtonHeight: 44
|
||||
|
||||
@@ -35,6 +35,7 @@ class SessionController(QObject):
|
||||
recordingChanged = Signal()
|
||||
sensorsChanged = Signal()
|
||||
loadedFileChanged = Signal()
|
||||
loadFileError = Signal(str)
|
||||
clusterAveragingEnabledChanged = Signal()
|
||||
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||
@@ -90,6 +91,11 @@ class SessionController(QObject):
|
||||
self._received_count: int = 0
|
||||
self._error_count: int = 0
|
||||
|
||||
# Comparison cache for dynamic overlap mapping
|
||||
self._compare_recs_a: list = []
|
||||
self._compare_recs_b: list = []
|
||||
self._compare_alignment_map: dict[int, int] = {}
|
||||
|
||||
# Restore persisted state if available
|
||||
self._load_settings(settings or {})
|
||||
|
||||
@@ -269,24 +275,25 @@ class SessionController(QObject):
|
||||
|
||||
frames = []
|
||||
|
||||
try:
|
||||
if is_official_csv(file_path):
|
||||
sensors, records = read_official_csv(file_path)
|
||||
frames = frames_from_wafer_data(None, records)
|
||||
else:
|
||||
try:
|
||||
data, _ = ZWaferParser().parse(file_path)
|
||||
except (ValueError, KeyError) as exc:
|
||||
log.warning("Could not parse %s: %s", file_path, exc)
|
||||
return
|
||||
if data is None or not data.sensors:
|
||||
log.warning("Could not parse %s", file_path)
|
||||
self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
|
||||
return
|
||||
records = read_data_records(file_path)
|
||||
sensors = data.sensors
|
||||
frames = frames_from_wafer_data(data, records)
|
||||
|
||||
if not sensors or not frames:
|
||||
log.warning("No sensors or data in %s", file_path)
|
||||
self.loadFileError.emit("No sensors or frames found in data file.")
|
||||
return
|
||||
except Exception as exc:
|
||||
log.warning("Could not parse file %s: %s", file_path, exc)
|
||||
self.loadFileError.emit(f"Load error: {exc}")
|
||||
return
|
||||
|
||||
wafer_id = ""
|
||||
@@ -360,6 +367,9 @@ class SessionController(QObject):
|
||||
})
|
||||
return
|
||||
|
||||
self._compare_recs_a = recs_a
|
||||
self._compare_recs_b = recs_b
|
||||
|
||||
len_a, len_b = len(recs_a), len(recs_b)
|
||||
# DTW can only align frames both runs actually have; the longer
|
||||
# run's extra tail is still returned for display, just unaligned.
|
||||
@@ -370,6 +380,7 @@ class SessionController(QObject):
|
||||
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
|
||||
|
||||
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
|
||||
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
|
||||
|
||||
# Mean temporal shift along the DTW path: positive means run B
|
||||
# reaches the same profile features later than run A.
|
||||
@@ -459,6 +470,25 @@ class SessionController(QObject):
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
@Slot(int, result="QVariantList")
|
||||
def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
|
||||
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
|
||||
if not self._compare_recs_a or not self._compare_recs_b:
|
||||
return []
|
||||
|
||||
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
|
||||
if idx_a not in self._compare_alignment_map:
|
||||
# DTW path only covers indices up to the shorter run's length —
|
||||
# past that, Run B has no data at this frame, not a clampable one.
|
||||
return []
|
||||
idx_b = self._compare_alignment_map[idx_a]
|
||||
|
||||
values_a = self._compare_recs_a[idx_a].values
|
||||
values_b = self._compare_recs_b[idx_b].values
|
||||
|
||||
n = min(len(values_a), len(values_b))
|
||||
return [round(values_b[i] - values_a[i], 3) for i in range(n)]
|
||||
|
||||
# ---- splitting: threshold-based segmentation ----
|
||||
@Slot(str, float)
|
||||
@slot_error_boundary
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||
@@ -190,9 +191,14 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
lo = min(self._data)
|
||||
hi = max(self._data)
|
||||
if hi - lo < 1e-9:
|
||||
return lo - 5.0, hi + 5.0
|
||||
buf = (hi - lo) * 0.1
|
||||
return lo - buf, hi + buf
|
||||
lo, hi = lo - 5.0, hi + 5.0
|
||||
# Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
|
||||
# while streaming and only moves when data crosses a step boundary —
|
||||
# raw min/max rescaled every frame, which read as an unstable chart.
|
||||
raw_step = (hi - lo) / 4
|
||||
mag = 10 ** math.floor(math.log10(raw_step))
|
||||
step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step)
|
||||
return math.floor(lo / step) * step, math.ceil(hi / step) * step
|
||||
|
||||
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
||||
if n <= 1:
|
||||
@@ -234,7 +240,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
t = i / rows
|
||||
y_val = y_max - t * (y_max - y_min)
|
||||
y_px = plot_rect.top() + t * plot_rect.height()
|
||||
label = f"{y_val:.1f}"
|
||||
label = f"{y_val:g}"
|
||||
painter.drawText(QRectF(0, y_px - 7, label_w, 14),
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||
label)
|
||||
@@ -250,12 +256,17 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
for i in range(cols + 1):
|
||||
idx = round((i / cols) * (n - 1))
|
||||
x_px = self._x_to_px(idx, n, plot_rect)
|
||||
align = (Qt.AlignmentFlag.AlignLeft if i == 0
|
||||
else Qt.AlignmentFlag.AlignRight if i == cols
|
||||
else Qt.AlignmentFlag.AlignHCenter)
|
||||
painter.drawText(QRectF(x_px - 20, y_px, 40, 14),
|
||||
align | Qt.AlignmentFlag.AlignTop,
|
||||
str(idx))
|
||||
if i == 0:
|
||||
rect = QRectF(x_px, y_px, 40, 14)
|
||||
align = Qt.AlignmentFlag.AlignLeft
|
||||
elif i == cols:
|
||||
# Right-anchor inside the plot so the label never clips off-widget
|
||||
rect = QRectF(x_px - 40, y_px, 40, 14)
|
||||
align = Qt.AlignmentFlag.AlignRight
|
||||
else:
|
||||
rect = QRectF(x_px - 20, y_px, 40, 14)
|
||||
align = Qt.AlignmentFlag.AlignHCenter
|
||||
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx))
|
||||
|
||||
def _draw_line(
|
||||
self,
|
||||
|
||||
@@ -347,6 +347,10 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._thickness_heatmap = None
|
||||
return
|
||||
field = refine_field(field)
|
||||
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||||
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||||
# Clamp to the real data range so color only reflects measured values.
|
||||
field = np.clip(field, vs.min(), vs.max())
|
||||
if self._shape == "round":
|
||||
alpha = ellipse_alpha(*field.shape)
|
||||
else:
|
||||
@@ -467,6 +471,10 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._heatmap = None
|
||||
return
|
||||
field = refine_field(field)
|
||||
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||||
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||||
# Clamp to the real data range so color only reflects measured values.
|
||||
field = np.clip(field, vs.min(), vs.max())
|
||||
if self._shape == "round":
|
||||
alpha = ellipse_alpha(*field.shape)
|
||||
else:
|
||||
@@ -609,11 +617,11 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
font_scale = 0.85
|
||||
|
||||
id_font = QFont()
|
||||
id_font.setPointSize(max(4, int(r * font_scale)))
|
||||
id_font.setPointSize(max(9, int(r * font_scale)))
|
||||
id_font.setBold(True)
|
||||
|
||||
temp_font = QFont()
|
||||
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
|
||||
temp_font.setPointSize(max(9, int(r * font_scale)))
|
||||
|
||||
band_color = {
|
||||
"in_range": self._in_range_color,
|
||||
|
||||
Reference in New Issue
Block a user