Skip to content

Commit 7f1766c

Browse files
author
Codex
committed
Add compensation overrides and figure export
1 parent 05967b8 commit 7f1766c

9 files changed

Lines changed: 663 additions & 53 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
Parallax is a local-first cytometry workstation built around one shared Rust engine, an explicit command log, and a native Qt/QML desktop. It is designed for teams who care about speed, deterministic results, and a clean handoff between interactive desktop work and reproducible execution.
1010

11-
Today, Parallax is an early but real workstation shell. You can launch the desktop, start from the bundled demo sample or import one or many `.fcs` files, switch between samples inside one local session, assign cohort labels to samples, author rectangle, polygon, quadrant, and histogram range gates, inspect a native histogram view for channel distributions, review per-population counts, frequencies, means, and medians, define replayable derived metrics such as positive fractions and mean ratios, compare the selected population across loaded samples, inspect grouped cohort summaries, export active-sample, selected-population, derived-metric, cohort-summary, or batch stats to CSV, apply replayable compensation and transform settings, adjust plot views through explicit view actions, inspect the command log, and undo or redo gate actions through the same replayable state model.
11+
Today, Parallax is an early but real workstation shell. You can launch the desktop, start from the bundled demo sample or import one or many `.fcs` files, switch between samples inside one local session, assign cohort labels to samples, author rectangle, polygon, quadrant, and histogram range gates, inspect a native histogram view for channel distributions, review per-population counts, frequencies, means, and medians, define replayable derived metrics such as positive fractions and mean ratios, compare the selected population across loaded samples, inspect grouped cohort summaries, export active-sample, selected-population, derived-metric, cohort-summary, or batch stats to CSV, apply or override compensation with QC feedback, export high-resolution PNG plot figures, adjust plot views through explicit view actions, inspect the command log, and undo or redo gate actions through the same replayable state model.
1212

1313
## Why Parallax
1414

@@ -38,8 +38,9 @@ Today, Parallax is an early but real workstation shell. You can launch the deskt
3838
- Qt/QML desktop with live rectangle, polygon, quadrant, and histogram range-gate authoring
3939
- Desktop FCS import and multi-sample switching in one local session
4040
- Workspace save/load that reopens sessions from sample sources plus replayable command history
41-
- Parsed FCS compensation toggle plus per-channel linear, signed-log10, asinh, biexponential, and logicle transform presets
41+
- Parsed FCS compensation toggle, manual spillover override with QC, plus per-channel linear, signed-log10, asinh, biexponential, and logicle transform presets
4242
- Native histogram panel with population-aware highlighting, drag-authored range gates, exact min/max threshold entry, and midpoint `Low Gate` / `High Gate` shortcuts
43+
- High-resolution PNG export for plot cards with interaction controls hidden during capture
4344
- Population stats panel with matched-event counts, parent/all frequencies, and per-channel mean/median summaries
4445
- CSV export for active-sample population stats
4546
- Active-sample gate-template application across the other loaded samples
@@ -57,8 +58,8 @@ Today, Parallax is an early but real workstation shell. You can launch the deskt
5758
- Multi-factor cohort labels, group template tools, and richer cohort-review layouts are not implemented yet
5859
- Workspace persistence is source-path based today; bundled raw-data snapshots and derived caches are not implemented yet
5960
- Derived metrics are limited to positive fraction and mean ratio today; custom formula editors and spreadsheet-style expressions are not implemented yet
60-
- Compensation override editing, contour/density plot controls, and reference-matched transform tuning are not implemented yet
61-
- Gate editing handles, freeform pan, and figure/report export are not implemented yet
61+
- Contour/density plot controls and reference-matched transform tuning are not implemented yet
62+
- Gate editing handles, freeform pan, PDF/SVG figure export, and multi-panel report export are not implemented yet
6263
- Cloud sync, jobs, and AI assistance are future phases
6364

6465
## Repository Layout

apps/desktop-qt/qml/Main.qml

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ApplicationWindow {
2020
property string derivedMetricDraftNumeratorChannel: ""
2121
property string derivedMetricDraftDenominatorChannel: ""
2222
property string derivedMetricDraftThreshold: "1.00"
23+
property bool figureExportInProgress: false
2324

2425
function transformIndex(kind) {
2526
if (kind === "signed_log10")
@@ -72,6 +73,37 @@ ApplicationWindow {
7273
Number(maxText))
7374
}
7475

76+
function safeFileStem(value) {
77+
const text = String(value || "parallax-figure").trim().replace(/[^A-Za-z0-9._-]+/g, "_")
78+
return text.length > 0 ? text : "parallax-figure"
79+
}
80+
81+
function exportPlotCard(card, plot) {
82+
const suggested = window.safeFileStem(plot.title || plot.id || "parallax-plot") + ".png"
83+
const path = desktopController.chooseFigureExportPath(suggested)
84+
if (path === "")
85+
return
86+
87+
window.figureExportInProgress = true
88+
Qt.callLater(function () {
89+
const targetWidth = Math.max(1800, Math.round(card.width * 3))
90+
const targetHeight = Math.max(1200, Math.round(card.height * 3))
91+
card.grabToImage(function (result) {
92+
const ok = result.saveToFile(path)
93+
window.figureExportInProgress = false
94+
if (!ok)
95+
desktopController.reportFigureExportFailure("Failed to save figure to " + path)
96+
}, Qt.size(targetWidth, targetHeight))
97+
})
98+
}
99+
100+
function compensationOverrideExample() {
101+
const channels = desktopController.sample.channels || []
102+
if (channels.length >= 2)
103+
return "2," + channels[0] + "," + channels[1] + ",1,0,0,1"
104+
return "2,FITC-A,PE-A,1,0,0,1"
105+
}
106+
75107
function formatPercent(value) {
76108
const numeric = Number(value)
77109
if (!isFinite(numeric))
@@ -723,7 +755,9 @@ ApplicationWindow {
723755
}
724756

725757
CheckBox {
726-
text: desktopController.sample.compensation_available
758+
text: desktopController.sample.compensation_override_active
759+
? "Apply Compensation Override"
760+
: desktopController.sample.compensation_available
727761
? "Apply Parsed Compensation"
728762
: "No Compensation Matrix In Sample"
729763
enabled: desktopController.sample.compensation_available || false
@@ -735,6 +769,7 @@ ApplicationWindow {
735769
width: parent.width
736770
text: desktopController.sample.compensation_source_key
737771
? "Source: " + desktopController.sample.compensation_source_key
772+
+ (desktopController.sample.compensation_override_active ? " (manual override)" : " (parsed from FCS)")
738773
: "Transforms and compensation are replayed before every gate redraw."
739774
color: "#6d5941"
740775
font.pixelSize: 13
@@ -811,6 +846,53 @@ ApplicationWindow {
811846
}
812847
}
813848

849+
Column {
850+
width: parent.width
851+
spacing: 6
852+
853+
Text {
854+
width: parent.width
855+
text: "Manual Compensation Override"
856+
color: "#2e2216"
857+
font.pixelSize: 14
858+
font.weight: Font.DemiBold
859+
}
860+
861+
Text {
862+
width: parent.width
863+
text: "Paste an FCS spillover string: dimension, channels, then row-major matrix values. Overrides are replayable and QC-checked before use."
864+
color: "#6d5941"
865+
font.pixelSize: 12
866+
wrapMode: Text.WordWrap
867+
}
868+
869+
TextArea {
870+
id: compensationOverrideField
871+
width: parent.width
872+
height: 84
873+
wrapMode: TextEdit.Wrap
874+
selectByMouse: true
875+
placeholderText: window.compensationOverrideExample()
876+
}
877+
878+
Row {
879+
spacing: 8
880+
881+
Button {
882+
text: "Apply Override"
883+
enabled: compensationOverrideField.text.trim().length > 0
884+
onClicked: desktopController.setCompensationOverrideFromText(
885+
compensationOverrideField.text)
886+
}
887+
888+
Button {
889+
text: "Clear Override"
890+
enabled: desktopController.sample.compensation_override_active || false
891+
onClicked: desktopController.clearCompensationOverride()
892+
}
893+
}
894+
}
895+
814896
Repeater {
815897
model: desktopController.sample.channel_transforms || []
816898

@@ -1526,6 +1608,7 @@ ApplicationWindow {
15261608
spacing: 18
15271609

15281610
Rectangle {
1611+
id: plotACard
15291612
Layout.fillWidth: true
15301613
Layout.preferredHeight: 1
15311614
Layout.fillHeight: true
@@ -1566,6 +1649,7 @@ ApplicationWindow {
15661649

15671650
RowLayout {
15681651
Layout.fillWidth: true
1652+
visible: !window.figureExportInProgress
15691653
spacing: 8
15701654

15711655
Button {
@@ -1606,6 +1690,12 @@ ApplicationWindow {
16061690
onClicked: desktopController.scalePlotView(plotA.id || "", 1.4)
16071691
}
16081692

1693+
Button {
1694+
text: "Export PNG"
1695+
enabled: !window.figureExportInProgress
1696+
onClicked: window.exportPlotCard(plotACard, plotA)
1697+
}
1698+
16091699
Item { Layout.fillWidth: true }
16101700

16111701
Text {
@@ -1616,6 +1706,7 @@ ApplicationWindow {
16161706
}
16171707

16181708
Text {
1709+
visible: !window.figureExportInProgress
16191710
text: window.plotHelperText(plotA)
16201711
color: "#8b6a3c"
16211712
font.pixelSize: 13
@@ -1624,6 +1715,7 @@ ApplicationWindow {
16241715
RowLayout {
16251716
Layout.fillWidth: true
16261717
visible: (plotA.kind || "") === "histogram"
1718+
&& !window.figureExportInProgress
16271719
spacing: 8
16281720

16291721
Text {
@@ -1720,6 +1812,7 @@ ApplicationWindow {
17201812
}
17211813

17221814
Rectangle {
1815+
id: plotBCard
17231816
Layout.fillWidth: true
17241817
Layout.preferredHeight: 1
17251818
Layout.fillHeight: true
@@ -1760,6 +1853,7 @@ ApplicationWindow {
17601853

17611854
RowLayout {
17621855
Layout.fillWidth: true
1856+
visible: !window.figureExportInProgress
17631857
spacing: 8
17641858

17651859
Button {
@@ -1800,6 +1894,12 @@ ApplicationWindow {
18001894
onClicked: desktopController.scalePlotView(plotB.id || "", 1.4)
18011895
}
18021896

1897+
Button {
1898+
text: "Export PNG"
1899+
enabled: !window.figureExportInProgress
1900+
onClicked: window.exportPlotCard(plotBCard, plotB)
1901+
}
1902+
18031903
Item { Layout.fillWidth: true }
18041904

18051905
Text {
@@ -1810,6 +1910,7 @@ ApplicationWindow {
18101910
}
18111911

18121912
Text {
1913+
visible: !window.figureExportInProgress
18131914
text: window.plotHelperText(plotB)
18141915
color: "#8b6a3c"
18151916
font.pixelSize: 13
@@ -1818,6 +1919,7 @@ ApplicationWindow {
18181919
RowLayout {
18191920
Layout.fillWidth: true
18201921
visible: (plotB.kind || "") === "histogram"
1922+
&& !window.figureExportInProgress
18211923
spacing: 8
18221924

18231925
Text {
@@ -1914,6 +2016,7 @@ ApplicationWindow {
19142016
}
19152017

19162018
Rectangle {
2019+
id: plotCCard
19172020
Layout.fillWidth: true
19182021
Layout.preferredHeight: 1
19192022
Layout.fillHeight: true
@@ -1955,6 +2058,7 @@ ApplicationWindow {
19552058

19562059
RowLayout {
19572060
Layout.fillWidth: true
2061+
visible: !window.figureExportInProgress
19582062
spacing: 8
19592063

19602064
Button {
@@ -1995,6 +2099,12 @@ ApplicationWindow {
19952099
onClicked: desktopController.scalePlotView(plotC.id || "", 1.4)
19962100
}
19972101

2102+
Button {
2103+
text: "Export PNG"
2104+
enabled: !window.figureExportInProgress
2105+
onClicked: window.exportPlotCard(plotCCard, plotC)
2106+
}
2107+
19982108
Item { Layout.fillWidth: true }
19992109

20002110
Text {
@@ -2005,6 +2115,7 @@ ApplicationWindow {
20052115
}
20062116

20072117
Text {
2118+
visible: !window.figureExportInProgress
20082119
text: window.plotHelperText(plotC)
20092120
color: "#8b6a3c"
20102121
font.pixelSize: 13
@@ -2013,6 +2124,7 @@ ApplicationWindow {
20132124
RowLayout {
20142125
Layout.fillWidth: true
20152126
visible: (plotC.kind || "") === "histogram"
2127+
&& !window.figureExportInProgress
20162128
spacing: 8
20172129

20182130
Text {

apps/desktop-qt/src/DesktopController.cpp

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,35 @@ void DesktopController::setCompensationEnabled(bool enabled) {
661661
QJsonDocument(command).toJson(QJsonDocument::Compact)));
662662
}
663663

664+
void DesktopController::setCompensationOverrideFromText(const QString &matrixText) {
665+
const QString trimmed = matrixText.trimmed();
666+
if (trimmed.isEmpty()) {
667+
setLastError("Compensation override cannot be empty");
668+
return;
669+
}
670+
671+
QJsonObject command;
672+
const QString sampleId = activeSampleId();
673+
command.insert("kind", "set_compensation_override");
674+
command.insert(
675+
"sample_id",
676+
sampleId.isEmpty() ? QStringLiteral("desktop-demo") : sampleId);
677+
command.insert("matrix_text", trimmed);
678+
dispatchCommandJson(QString::fromUtf8(
679+
QJsonDocument(command).toJson(QJsonDocument::Compact)));
680+
}
681+
682+
void DesktopController::clearCompensationOverride() {
683+
QJsonObject command;
684+
const QString sampleId = activeSampleId();
685+
command.insert("kind", "clear_compensation_override");
686+
command.insert(
687+
"sample_id",
688+
sampleId.isEmpty() ? QStringLiteral("desktop-demo") : sampleId);
689+
dispatchCommandJson(QString::fromUtf8(
690+
QJsonDocument(command).toJson(QJsonDocument::Compact)));
691+
}
692+
664693
void DesktopController::setChannelTransform(const QString &channel, const QString &kind) {
665694
if (channel.trimmed().isEmpty()) {
666695
setLastError("Transform channel cannot be empty");
@@ -701,6 +730,28 @@ void DesktopController::setChannelTransform(const QString &channel, const QStrin
701730
QJsonDocument(command).toJson(QJsonDocument::Compact)));
702731
}
703732

733+
QString DesktopController::chooseFigureExportPath(const QString &suggestedName) {
734+
QString candidate = suggestedName.trimmed();
735+
if (candidate.isEmpty()) {
736+
candidate = QStringLiteral("parallax-figure.png");
737+
}
738+
if (!candidate.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive)) {
739+
candidate.append(QStringLiteral(".png"));
740+
}
741+
742+
return QFileDialog::getSaveFileName(
743+
nullptr,
744+
tr("Export Publication Figure"),
745+
candidate,
746+
tr("PNG Images (*.png);;All Files (*)"));
747+
}
748+
749+
void DesktopController::reportFigureExportFailure(const QString &message) {
750+
setLastError(message.trimmed().isEmpty()
751+
? QStringLiteral("Failed to export figure")
752+
: message.trimmed());
753+
}
754+
704755
void DesktopController::resetPlotView(const QString &plotId) {
705756
if (plotId.trimmed().isEmpty()) {
706757
setLastError("Plot id cannot be empty");

apps/desktop-qt/src/DesktopController.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ class DesktopController : public QObject {
8686
const QString &numeratorChannel,
8787
const QString &denominatorChannel);
8888
Q_INVOKABLE void setCompensationEnabled(bool enabled);
89+
Q_INVOKABLE void setCompensationOverrideFromText(const QString &matrixText);
90+
Q_INVOKABLE void clearCompensationOverride();
8991
Q_INVOKABLE void setChannelTransform(const QString &channel, const QString &kind);
92+
Q_INVOKABLE QString chooseFigureExportPath(const QString &suggestedName);
93+
Q_INVOKABLE void reportFigureExportFailure(const QString &message);
9094
Q_INVOKABLE void resetPlotView(const QString &plotId);
9195
Q_INVOKABLE void focusPlotOnSelectedPopulation(const QString &plotId);
9296
Q_INVOKABLE void scalePlotView(const QString &plotId, double factor);

0 commit comments

Comments
 (0)