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.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
@@ -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,125 +771,15 @@ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
|
|
||||||
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
|
spacing: 8
|
||||||
anchors.centerIn: parent
|
|
||||||
Label {
|
SectionTitle { text: "DTW READOUT" }
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: root.comparing
|
ReadoutCard {
|
||||||
? "Comparing…"
|
label: "DTW ALIGNMENT DISTANCE"
|
||||||
: compareBtn.enabled
|
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
|
||||||
? "Run DTW Comparison"
|
valueColor: root.warpingDistance >= 0
|
||||||
: (compareBtn.sameFile ? "Choose different runs" : "Select both runs")
|
? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
|
||||||
color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
BusyIndicator {
|
|
||||||
running: root.comparing
|
|
||||||
visible: root.comparing
|
|
||||||
width: 14; height: 14
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|
||||||
|
try:
|
||||||
if is_official_csv(file_path):
|
if is_official_csv(file_path):
|
||||||
sensors, records = read_official_csv(file_path)
|
sensors, records = read_official_csv(file_path)
|
||||||
frames = frames_from_wafer_data(None, records)
|
frames = frames_from_wafer_data(None, records)
|
||||||
else:
|
else:
|
||||||
try:
|
|
||||||
data, _ = ZWaferParser().parse(file_path)
|
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:
|
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
|
return
|
||||||
records = read_data_records(file_path)
|
records = read_data_records(file_path)
|
||||||
sensors = data.sensors
|
sensors = data.sensors
|
||||||
frames = frames_from_wafer_data(data, records)
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user