feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab

This commit is contained in:
jack
2026-07-07 15:05:22 -07:00
parent 1e03227788
commit 92f130b3bd
8 changed files with 798 additions and 428 deletions
+287 -7
View File
@@ -181,8 +181,6 @@ Rectangle {
Layout.preferredWidth: Theme.sideRailWidth Layout.preferredWidth: Theme.sideRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.sideRailBackground color: Theme.sideRailBackground
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
anchors.fill: parent 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 ──────────────────── // ── BOX 2: Context-Sensitive Rail Panel ────────────────────
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
@@ -363,20 +641,23 @@ Rectangle {
} }
// ── BOX 3: Hardware Status Footer ────────────────────────── // ── BOX 3: Hardware Status Footer ──────────────────────────
// Not relevant on the Data tab — Run Comparison takes its place above.
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterConnection Layout.preferredHeight: Theme.sideFooterConnection
visible: root.selectedTabIndex !== 1
radius: Theme.sidePanelRadius radius: Theme.sidePanelRadius
border.color: Theme.sideBorder border.color: Theme.sideBorder
border.width: 1 border.width: 1
color: Theme.sidePanelBackground color: Theme.sidePanelBackground
clip: true
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 14 anchors.margins: 14
spacing: 6 spacing: 6
// Row 1: Title + status badge (unchanged) // Row 1: Title + status badge
RowLayout { RowLayout {
spacing: 8 spacing: 8
Layout.fillWidth: true Layout.fillWidth: true
@@ -388,12 +669,15 @@ Rectangle {
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.bold: true font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
} }
Rectangle { Rectangle {
height: 18 height: 18
width: badgeLabel.implicitWidth + 14 width: badgeLabel.implicitWidth + 14
radius: 9 radius: 9
Layout.alignment: Qt.AlignVCenter
color: { color: {
var s = deviceController.connectionStatus var s = deviceController.connectionStatus
if (s === "Connected") return Theme.statusSuccessColor if (s === "Connected") return Theme.statusSuccessColor
@@ -410,8 +694,6 @@ Rectangle {
font.bold: true font.bold: true
} }
} }
Item { Layout.fillWidth: true }
} }
// Row 2: Status dot + descriptive text (replaces dotted trail) // Row 2: Status dot + descriptive text (replaces dotted trail)
@@ -595,8 +877,6 @@ Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.workspaceBackground color: Theme.workspaceBackground
border.color: Theme.workspaceBorder
border.width: 1
StackLayout { StackLayout {
anchors.fill: parent anchors.fill: parent
+330 -335
View File
@@ -20,6 +20,23 @@ Item {
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
property string compareFileA: "" property string compareFileA: ""
property string compareFileB: "" 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 real warpingDistance: -1
property int frameOffset: 0 property int frameOffset: 0
property real frameOffsetSeconds: 0 property real frameOffsetSeconds: 0
@@ -57,6 +74,27 @@ Item {
return t !== null ? " (" + t.toFixed(1) + "s)" : "" 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() { function clearResults() {
root.warpingDistance = -1 root.warpingDistance = -1
root.frameOffset = 0 root.frameOffset = 0
@@ -78,6 +116,7 @@ Item {
function resetComparison() { function resetComparison() {
root.compareFileA = "" root.compareFileA = ""
root.compareFileB = "" root.compareFileB = ""
root.useMasterFile = false
root.comparing = false root.comparing = false
root.activeBox = 1 root.activeBox = 1
clearResults() clearResults()
@@ -124,6 +163,7 @@ Item {
root.compareWaferSize = result.wafer_size || 300.0 root.compareWaferSize = result.wafer_size || 300.0
root.errorMessage = "" root.errorMessage = ""
scrubber.value = Math.floor(root.seriesLen / 2) scrubber.value = Math.floor(root.seriesLen / 2)
scrubber.forceActiveFocus()
if (root.frameCountA !== root.frameCountB) { if (root.frameCountA !== root.frameCountB) {
var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0 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 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 { component ScrubStat: Rectangle {
id: scrubStat id: scrubStat
property string label property string label
property string value property string value
property color valueColor: Theme.headingColor property color valueColor: Theme.headingColor
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 38 implicitHeight: 52
radius: 4 radius: Theme.radiusSm
color: Theme.cardWash color: Theme.subtleSectionBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1 border.width: 1
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: 6 anchors.margins: 8
anchors.rightMargin: 6 spacing: 2
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 0
Label { Label {
text: scrubStat.label text: scrubStat.label.toUpperCase()
font.pixelSize: 9 font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText color: Theme.sideMutedText
} }
Label { Label {
text: scrubStat.value text: scrubStat.value
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
color: scrubStat.valueColor 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 // Large labeled value card for the DTW readout panel
component ReadoutCard: Rectangle { component ReadoutCard: Rectangle {
id: readoutCard 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 ─────────────────────────────────────────────── // ── Main Layout ───────────────────────────────────────────────
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap 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 ─────────────────────────────────────────── // ── Error Banner ───────────────────────────────────────────
Rectangle { Rectangle {
@@ -454,6 +483,79 @@ Item {
Layout.minimumWidth: 350 Layout.minimumWidth: 350
spacing: 12 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 // Chart card with alignment scrubber
PanelBox { PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
@@ -478,6 +580,7 @@ Item {
spacing: 4 spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter } 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 } Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
} }
Row { Row {
spacing: 4 spacing: 4
@@ -668,129 +771,19 @@ Item {
Slider { Slider {
id: scrubber id: scrubber
Layout.fillWidth: true Layout.fillWidth: true
leftPadding: overlayCanvas.padLeft
rightPadding: overlayCanvas.padRight
from: 0 from: 0
to: Math.max(1, root.seriesLen - 1) to: Math.max(1, root.seriesLen - 1)
stepSize: 1 stepSize: 1
value: 0 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 --- // --- Right Column: run selection, overlap settings, readout ---
ColumnLayout { ColumnLayout {
@@ -801,85 +794,40 @@ Item {
Layout.fillHeight: true Layout.fillHeight: true
spacing: 12 spacing: 12
// Bento 1: Run Selection // Bento 3: DTW Comparison Readout
PanelBox { PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: runSelCol.implicitHeight + 24 Layout.preferredHeight: readoutCol.implicitHeight + 24
ColumnLayout { ColumnLayout {
id: runSelCol id: readoutCol
anchors.fill: parent anchors.fill: parent
anchors.margins: 12 anchors.margins: 12
spacing: 10 spacing: 8
SectionTitle { text: "RUN SELECTION" } SectionTitle { text: "DTW READOUT" }
RunSlot { ReadoutCard {
title: "REFERENCE (RUN A)" label: "DTW ALIGNMENT DISTANCE"
boxIndex: 1 value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
accent: Theme.primaryAccent valueColor: root.warpingDistance >= 0
filePath: root.compareFileA ? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
onCleared: { : Theme.sideMutedText
root.compareFileA = ""
root.activeBox = 1
root.clearResults()
}
} }
ReadoutCard {
RunSlot { label: "TEMPORAL FRAME OFFSET"
title: "SESSION (RUN B)" value: root.warpingDistance >= 0
boxIndex: 2 ? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / "
accent: Theme.themeSkill + (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s")
filePath: root.compareFileB : "--"
onCleared: { valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
root.compareFileB = ""
root.activeBox = 2
root.clearResults()
}
} }
ReadoutCard {
Button { label: "MAX SENSOR DEVIATION"
id: compareBtn value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
Layout.fillWidth: true valueColor: root.maxSensorDeviation >= 0
Layout.preferredHeight: 32 ? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
readonly property bool sameFile: root.compareFileA !== "" && root.compareFileA === root.compareFileB : Theme.sideMutedText
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
}
}
} }
} }
} }
@@ -946,57 +894,104 @@ Item {
Row { Row {
spacing: 3 spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter } 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 { Row {
spacing: 3 spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter } 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 { Row {
spacing: 3 spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter } 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 { PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: readoutCol.implicitHeight + 24 Layout.preferredHeight: frameTimeCol.implicitHeight + 32
ColumnLayout { 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.fill: parent
anchors.margins: 12 anchors.margins: 12
spacing: 8 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 { SectionTitle { text: "SCRUBBER READOUT" }
label: "DTW ALIGNMENT DISTANCE"
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--" ScrubStat {
valueColor: root.warpingDistance >= 0 label: "Temp (Run A)"
? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad) value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
: Theme.sideMutedText valueColor: Theme.primaryAccent
} }
ReadoutCard { ScrubStat {
label: "TEMPORAL FRAME OFFSET" label: "Temp (Run B)"
value: root.warpingDistance >= 0 value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / " valueColor: Theme.themeSkill
+ (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s")
: "--"
valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
} }
ReadoutCard { ScrubStat {
label: "MAX SENSOR DEVIATION" label: "Difference"
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--" value: (scrubReadout.hasA && scrubReadout.hasB)
valueColor: root.maxSensorDeviation >= 0 ? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
: Theme.sideMutedText
} }
} }
} }
+44 -50
View File
@@ -74,8 +74,8 @@ Item {
} }
TabButton { TabButton {
text: "Review" text: "Review"
implicitWidth: 64 implicitWidth: 84
implicitHeight: 28 implicitHeight: 34
background: Rectangle { background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent" color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1 radius: Theme.radiusSm - 1
@@ -83,29 +83,61 @@ Item {
contentItem: Text { contentItem: Text {
text: parent.text text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontMd
font.weight: parent.checked ? Font.Medium : Font.Normal font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
} }
TabButton { TabButton {
id: liveTab
text: "Live" text: "Live"
enabled: deviceController.connectionStatus === "Connected" enabled: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4 opacity: enabled ? 1.0 : 0.4
implicitWidth: 58 implicitWidth: 84
implicitHeight: 28 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 { background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent" color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1 radius: Theme.radiusSm - 1
} }
contentItem: Text { contentItem: Row {
text: parent.text spacing: 5
color: parent.checked ? Theme.headingColor : Theme.bodyColor leftPadding: 8
font.pixelSize: Theme.fontSm Rectangle {
font.weight: parent.checked ? Font.Medium : Font.Normal id: liveTabDot
horizontalAlignment: Text.AlignHCenter width: 7; height: 7; radius: 3.5
verticalAlignment: Text.AlignVCenter 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) { 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 { Item {
Layout.fillWidth: true Layout.fillWidth: true
} }
+59 -7
View File
@@ -190,14 +190,35 @@ ColumnLayout {
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0; 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 var dataTab: {
readonly property bool isActive:{
try { try {
if (root.selectedTabIndex === 1 && dataTabLoader.item) { return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB; } 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 readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
@@ -233,6 +254,8 @@ ColumnLayout {
bottomMargin: 5 bottomMargin: 5
} }
color: { color: {
if (fileItem.isRunA) return Theme.primaryAccent;
if (fileItem.isRunB) return Theme.themeSkill;
var t = modelData.waferType; var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") if (t === "A" || t === "E" || t === "P")
return Theme.familyBlueAccent; return Theme.familyBlueAccent;
@@ -290,6 +313,16 @@ ColumnLayout {
anchors.topMargin: -2 anchors.topMargin: -2
anchors.rightMargin: -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 // Typography hierarchy
@@ -329,6 +362,25 @@ ColumnLayout {
font.letterSpacing: 0.6 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 // Secondary: date · time
+1 -1
View File
@@ -205,7 +205,7 @@ QtObject {
readonly property int rightPaneGap: 6 readonly property int rightPaneGap: 6
readonly property int panelPadding: 12 readonly property int panelPadding: 12
// Side rail layout // Side rail layout
readonly property int sideRailWidth: 280 readonly property int sideRailWidth: 300
readonly property int sideRailMargin: 16 readonly property int sideRailMargin: 16
readonly property int sideRailSpacing: 16 readonly property int sideRailSpacing: 16
readonly property int sideButtonHeight: 44 readonly property int sideButtonHeight: 44
@@ -35,6 +35,7 @@ class SessionController(QObject):
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
@@ -90,6 +91,11 @@ class SessionController(QObject):
self._received_count: int = 0 self._received_count: int = 0
self._error_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 # Restore persisted state if available
self._load_settings(settings or {}) self._load_settings(settings or {})
@@ -269,24 +275,25 @@ class SessionController(QObject):
frames = [] frames = []
if is_official_csv(file_path): try:
sensors, records = read_official_csv(file_path) if is_official_csv(file_path):
frames = frames_from_wafer_data(None, records) sensors, records = read_official_csv(file_path)
else: frames = frames_from_wafer_data(None, records)
try: else:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc: if data is None or not data.sensors:
log.warning("Could not parse %s: %s", file_path, exc) self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
return return
if data is None or not data.sensors: records = read_data_records(file_path)
log.warning("Could not parse %s", file_path) sensors = data.sensors
return frames = frames_from_wafer_data(data, records)
records = read_data_records(file_path)
sensors = data.sensors
frames = frames_from_wafer_data(data, records)
if not sensors or not frames: 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 return
wafer_id = "" wafer_id = ""
@@ -360,6 +367,9 @@ class SessionController(QObject):
}) })
return return
self._compare_recs_a = recs_a
self._compare_recs_b = recs_b
len_a, len_b = len(recs_a), len(recs_b) len_a, len_b = len(recs_a), len(recs_b)
# DTW can only align frames both runs actually have; the longer # DTW can only align frames both runs actually have; the longer
# run's extra tail is still returned for display, just unaligned. # 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)] 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]) 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 # Mean temporal shift along the DTW path: positive means run B
# reaches the same profile features later than run A. # reaches the same profile features later than run A.
@@ -459,6 +470,25 @@ class SessionController(QObject):
"error": str(exc) "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 ---- # ---- splitting: threshold-based segmentation ----
@Slot(str, float) @Slot(str, float)
@slot_error_boundary @slot_error_boundary
@@ -12,6 +12,7 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from typing import Optional from typing import Optional
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
@@ -190,9 +191,14 @@ class TrendChartItem(QQuickPaintedItem):
lo = min(self._data) lo = min(self._data)
hi = max(self._data) hi = max(self._data)
if hi - lo < 1e-9: if hi - lo < 1e-9:
return lo - 5.0, hi + 5.0 lo, hi = lo - 5.0, hi + 5.0
buf = (hi - lo) * 0.1 # Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
return lo - buf, hi + buf # 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: def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1: if n <= 1:
@@ -234,7 +240,7 @@ class TrendChartItem(QQuickPaintedItem):
t = i / rows t = i / rows
y_val = y_max - t * (y_max - y_min) y_val = y_max - t * (y_max - y_min)
y_px = plot_rect.top() + t * plot_rect.height() 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), painter.drawText(QRectF(0, y_px - 7, label_w, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label) label)
@@ -250,12 +256,17 @@ class TrendChartItem(QQuickPaintedItem):
for i in range(cols + 1): for i in range(cols + 1):
idx = round((i / cols) * (n - 1)) idx = round((i / cols) * (n - 1))
x_px = self._x_to_px(idx, n, plot_rect) x_px = self._x_to_px(idx, n, plot_rect)
align = (Qt.AlignmentFlag.AlignLeft if i == 0 if i == 0:
else Qt.AlignmentFlag.AlignRight if i == cols rect = QRectF(x_px, y_px, 40, 14)
else Qt.AlignmentFlag.AlignHCenter) align = Qt.AlignmentFlag.AlignLeft
painter.drawText(QRectF(x_px - 20, y_px, 40, 14), elif i == cols:
align | Qt.AlignmentFlag.AlignTop, # Right-anchor inside the plot so the label never clips off-widget
str(idx)) 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( def _draw_line(
self, self,
@@ -347,6 +347,10 @@ class WaferMapItem(QQuickPaintedItem):
self._thickness_heatmap = None self._thickness_heatmap = None
return return
field = refine_field(field) 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": if self._shape == "round":
alpha = ellipse_alpha(*field.shape) alpha = ellipse_alpha(*field.shape)
else: else:
@@ -467,6 +471,10 @@ class WaferMapItem(QQuickPaintedItem):
self._heatmap = None self._heatmap = None
return return
field = refine_field(field) 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": if self._shape == "round":
alpha = ellipse_alpha(*field.shape) alpha = ellipse_alpha(*field.shape)
else: else:
@@ -609,11 +617,11 @@ class WaferMapItem(QQuickPaintedItem):
font_scale = 0.85 font_scale = 0.85
id_font = QFont() 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) id_font.setBold(True)
temp_font = QFont() temp_font = QFont()
temp_font.setPointSize(max(3, int((r - 1) * font_scale))) temp_font.setPointSize(max(9, int(r * font_scale)))
band_color = { band_color = {
"in_range": self._in_range_color, "in_range": self._in_range_color,