Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 68 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import PlatesGrid from './components/PlatesGrid';
import QualityMetricsPanel from './components/QualityMetricsPanel';
import QualityLegend from './components/QualityLegend';
import SubjectPlacementPanel from './components/SubjectPlacementPanel';
import { SearchData, RandomizationAlgorithm, GroupingConstraint, GroupValidationResult, RepeatedMeasuresConfig } from './utils/types';
import { downloadCSV, buildProcessedSearches, getCovariateKey, getQualityLevelColor, formatScore, withTimestamp, buildLayoutFileName } from './utils/utils';
import { SearchData, RandomizationAlgorithm, GroupingConstraint, GroupValidationResult, RepeatedMeasuresConfig, NaPolicy, DEFAULT_NA_POLICY } from './utils/types';
import { downloadCSV, buildProcessedSearches, getCovariateKey, getQualityLevelColor, formatScore, withTimestamp, buildLayoutFileName, detectNaTypeValues } from './utils/utils';
import { exportToExcel } from './utils/excelExport';
import {
serializeLayout,
Expand Down Expand Up @@ -115,6 +115,9 @@ const App: React.FC = () => {
const [qcColumn, setQcColumn] = useState<string>('');
const [qcColumnValues, setQcColumnValues] = useState<string[]>([]);
const [selectedQcValues, setSelectedQcValues] = useState<string[]>([]);
// Global N/A grouping choice. Set from the "N/A values" checklist when the data mixes
// spellings; the default folds nothing extra (blank stays distinct, spellings stay literal).
const [naPolicy, setNaPolicy] = useState<NaPolicy>(DEFAULT_NA_POLICY);

// Algorithm selection
const [selectedAlgorithm, setSelectedAlgorithm] = useState<RandomizationAlgorithm>(defaultAlgorithm);
Expand Down Expand Up @@ -217,6 +220,9 @@ const App: React.FC = () => {
setGroupingConstraint('none');
setGroupValidation(null);

// N/A grouping (re-derived from the new data by the upload effect)
setNaPolicy(DEFAULT_NA_POLICY);

// Algorithm and plate dimensions (back to defaults)
setSelectedAlgorithm(defaultAlgorithm);
setKeepEmptyInLastPlate(false);
Expand Down Expand Up @@ -254,6 +260,16 @@ const App: React.FC = () => {
// IDs blank) still replaces the previous design instead of leaving it on screen.
if (selectedFileName) {
clearConfigAndLayout();
// Initialize the N/A policy from the new data. When a column mixes spellings, default to
// folding every detected spelling (all checklist boxes checked); otherwise fold nothing.
setNaPolicy(
naDetection.hasAmbiguousColumn
? {
foldBlank: naDetection.spellings.has(''),
foldSpellings: Array.from(naDetection.spellings).filter(t => t !== '' && t !== 'N/A'),
Comment thread
vagisha marked this conversation as resolved.
}
: DEFAULT_NA_POLICY
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFileName, searches.length]);
Expand Down Expand Up @@ -292,6 +308,31 @@ const App: React.FC = () => {
setShowSubjectPlacements(false);
};

// Scan every metadata column of the uploaded data for N/A-type spellings. Drives the "N/A
// values" checklist and, at upload, the default policy. The ID column is not metadata, so it
// is excluded automatically.
const naDetection = useMemo(
() => detectNaTypeValues(searches, availableColumns.filter(col => col !== selectedIdColumn)),
[searches, availableColumns, selectedIdColumn]
);

// Toggle one entry of the "N/A values" checklist. Blank uses the empty-string token. The
// literal N/A is always folded (its box is disabled), so it never reaches here. Changing the
// policy invalidates the current layout, matching a covariate change, so the user re-Generates.
const handleNaPolicyToggle = (token: string) => {
setNaPolicy(prev => {
if (token === '') return { ...prev, foldBlank: !prev.foldBlank };
const folded = prev.foldSpellings.includes(token);
return {
...prev,
foldSpellings: folded
? prev.foldSpellings.filter(s => s !== token)
: [...prev.foldSpellings, token],
};
});
resetCovariateState();
};

// Derive the available QC values for the chosen QC column. This only recomputes the
// list of checkboxes to show; it must NOT reset the current selection. A layout load
// sets qcColumn and searches together, which retriggers this effect, and resetting here
Expand Down Expand Up @@ -422,6 +463,7 @@ const App: React.FC = () => {
selectedCovariates,
qcColumn,
selectedQcValues,
naPolicy,
});
};

Expand All @@ -447,7 +489,8 @@ const App: React.FC = () => {
keepEmptyInLastPlate,
plateRows,
plateColumns,
repeatedMeasuresConfig
repeatedMeasuresConfig,
naPolicy
);

if (success) {
Expand All @@ -465,7 +508,8 @@ const App: React.FC = () => {
searches,
selectedCovariates,
qcColumn,
selectedQcValues
selectedQcValues,
naPolicy
);
}
} catch (err: any) {
Expand Down Expand Up @@ -495,6 +539,7 @@ const App: React.FC = () => {
groupingConstraint,
// Metadata columns in display order (every column except the ID column).
metadataColumns: availableColumns.filter(col => col !== selectedIdColumn),
naPolicy,
});

// Save layout handler - export the layout together with the settings that produced it.
Expand All @@ -511,7 +556,8 @@ const App: React.FC = () => {
appVersion: packageJson.version,
});
} catch (e) {
// serializeLayout throws if a sample is not on the grid. Show it instead of downloading.
// serializeLayout throws if a sample is not on the grid, or if there are no covariate
// colors to save. Show the message instead of downloading.
setLoadWarning((e as Error).message);
return;
}
Expand Down Expand Up @@ -557,6 +603,7 @@ const App: React.FC = () => {
setPlateColumns(settings.plateColumns);
setSubjectColumn(settings.subjectColumn);
setGroupingConstraint(settings.groupingConstraint);
setNaPolicy(settings.naPolicy);

// Restore the plates directly (no re-randomization).
restoreLayout(plates, plateAssignmentsToRestore);
Expand All @@ -579,7 +626,8 @@ const App: React.FC = () => {
loadedSearches,
settings.selectedCovariates,
settings.qcColumn,
settings.selectedQcValues
settings.selectedQcValues,
settings.naPolicy
);

// Clear any stale highlight/error.
Expand Down Expand Up @@ -619,11 +667,13 @@ const App: React.FC = () => {
samples: loadedSearches,
} = buildPlatesFromRows(parsed.rows, settings);

// Recompute covariate keys / QC flags (shared references update the plates too).
// Recompute covariate keys / QC flags (shared references update the plates too). Use the
// saved N/A policy so the derived keys match the stored covariate colors.
buildProcessedSearches(loadedSearches, {
selectedCovariates: settings.selectedCovariates,
qcColumn: settings.qcColumn,
selectedQcValues: settings.selectedQcValues,
naPolicy: settings.naPolicy,
});

applyLoadedLayout(
Expand Down Expand Up @@ -746,7 +796,8 @@ const App: React.FC = () => {
numRows: plateRows,
numColumns: plateColumns,
inputFileName: selectedFileName,
qcColumn: qcColumn || undefined
qcColumn: qcColumn || undefined,
naPolicy
});
};

Expand All @@ -772,7 +823,8 @@ const App: React.FC = () => {
keepEmptyInLastPlate,
plateRows,
plateColumns,
repeatedMeasuresConfig
repeatedMeasuresConfig,
naPolicy
);
}
};
Expand All @@ -799,7 +851,8 @@ const App: React.FC = () => {
keepEmptyInLastPlate,
plateRows,
plateColumns,
repeatedMeasuresConfig
repeatedMeasuresConfig,
naPolicy
);
// Quality metrics will be recalculated automatically via useEffect
}
Expand Down Expand Up @@ -947,6 +1000,9 @@ const App: React.FC = () => {
qcColumnValues={qcColumnValues}
selectedQcValues={selectedQcValues}
onQcValueToggle={handleQcValueToggle}
naDetection={naDetection}
naPolicy={naPolicy}
onNaPolicyToggle={handleNaPolicyToggle}
selectedAlgorithm={selectedAlgorithm}
onAlgorithmChange={handleAlgorithmChange}
keepEmptyInLastPlate={keepEmptyInLastPlate}
Expand Down Expand Up @@ -1097,6 +1153,7 @@ const App: React.FC = () => {
onReRandomizePlate={handleReRandomizePlate}
qualityMetrics={metrics ?? undefined}
subjectColumn={subjectColumn || undefined}
qcColumn={qcColumn || undefined}
/>
</>
)}
Expand All @@ -1120,6 +1177,7 @@ const App: React.FC = () => {
plateQuality={selectedPlateIndex !== null ? metrics?.plateDiversity.plateScores.find(score => score.plateIndex === selectedPlateIndex) : undefined}
randomizedPlates={randomizedPlates}
numPlates={randomizedPlates.length}
naPolicy={naPolicy}
/>

{/* Quality Assessment Modal */}
Expand Down
7 changes: 4 additions & 3 deletions src/algorithms/repeatedMeasuresDistribution.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SearchData, SubjectGroup, GroupingConstraint, GroupValidationResult, RepeatedMeasuresConfig, BlockType } from '../utils/types';
import { SearchData, SubjectGroup, GroupingConstraint, GroupValidationResult, RepeatedMeasuresConfig, BlockType, NaPolicy, DEFAULT_NA_POLICY } from '../utils/types';
import { shuffleArray, groupByCovariates, buildCovariateKey } from '../utils/utils';
import { greedyPlaceInRow } from './greedySpatialPlacement';
import { distributeToBlocks, calculateExpectedMinimums } from './balancedRandomization';
Expand Down Expand Up @@ -1165,7 +1165,8 @@ export function groupAwareRandomization(
repeatedMeasuresConfig: RepeatedMeasuresConfig,
keepEmptyInLastPlate: boolean,
numRows: number,
numColumns: number
numColumns: number,
naPolicy: NaPolicy = DEFAULT_NA_POLICY
): {
plates: (SearchData | undefined)[][][];
plateAssignments?: Map<number, SearchData[]>;
Expand All @@ -1188,7 +1189,7 @@ export function groupAwareRandomization(
// Use the single key builder so escaping can't drift from buildProcessedSearches.
for (const sample of experimentalSamples) {
if (!sample.covariateKey && selectedCovariates.length > 0) {
sample.covariateKey = buildCovariateKey(sample, { selectedCovariates });
sample.covariateKey = buildCovariateKey(sample, { selectedCovariates, naPolicy });
}
}

Expand Down
71 changes: 70 additions & 1 deletion src/components/ConfigurationForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { RandomizationAlgorithm, getAlgorithmName, getAlgorithmDescription, getAlgorithmsInDisplayOrder, GroupingConstraint, GroupValidationResult, SubjectGroup } from '../utils/types';
import { RandomizationAlgorithm, getAlgorithmName, getAlgorithmDescription, getAlgorithmsInDisplayOrder, GroupingConstraint, GroupValidationResult, SubjectGroup, NaPolicy } from '../utils/types';
import { NaDetectionResult } from '../utils/utils';

interface ConfigurationFormProps {
availableColumns: string[];
Expand All @@ -15,6 +16,9 @@ interface ConfigurationFormProps {
qcColumnValues: string[];
selectedQcValues: string[];
onQcValueToggle: (value: string) => void;
naDetection: NaDetectionResult;
naPolicy: NaPolicy;
onNaPolicyToggle: (token: string) => void;
selectedAlgorithm: RandomizationAlgorithm;
onAlgorithmChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
keepEmptyInLastPlate: boolean;
Expand Down Expand Up @@ -46,6 +50,9 @@ const ConfigurationForm: React.FC<ConfigurationFormProps> = ({
qcColumnValues: qcColumnValues,
selectedQcValues: selectedQcValues,
onQcValueToggle: onQcValueToggle,
naDetection,
naPolicy,
onNaPolicyToggle,
selectedAlgorithm,
onAlgorithmChange,
keepEmptyInLastPlate,
Expand Down Expand Up @@ -123,6 +130,16 @@ const ConfigurationForm: React.FC<ConfigurationFormProps> = ({

const groupSummary = subjectColumn ? buildGroupSummary() : null;

// "N/A values" checklist: shown only when some column mixes two or more N/A-type spellings.
// The union of spellings is listed with the literal N/A first, then blank, then the rest
// alphabetically.
const showNaValues = naDetection.hasAmbiguousColumn;
const naTokenRank = (token: string) => (token === 'N/A' ? 0 : token === '' ? 1 : 2);
const naTokens = Array.from(naDetection.spellings).sort((a, b) => {
const rankDiff = naTokenRank(a) - naTokenRank(b);
return rankDiff !== 0 ? rankDiff : a.localeCompare(b);
});

return (
<div style={styles.compactFormContainer}>
{/* Uploaded file name, shown above the configuration options */}
Expand Down Expand Up @@ -424,6 +441,46 @@ const ConfigurationForm: React.FC<ConfigurationFormProps> = ({
)}
</div>

{/* N/A values: one global choice shown only when the data mixes not-applicable spellings */}
{showNaValues && (
<div style={styles.compactRow}>
<div style={styles.fullWidthColumn}>
<div style={styles.naValuesContainer}>
<label style={styles.compactLabel}>N/A values:</label>
<small style={styles.compactHint}>
The input contains the following values that could be interpreted as N/A. Checked
values will be grouped as N/A. Uncheck any that should be kept separate.
</small>
<div style={styles.naCheckboxGroup}>
{naTokens.map((token) => {
const isBlank = token === '';
const isLiteralNa = token === 'N/A';
const label = isBlank ? '(blank)' : token;
const checked = isLiteralNa
? true
: isBlank
? naPolicy.foldBlank
: naPolicy.foldSpellings.includes(token);
return (
<label key={label} style={styles.checkboxLabel}>
<input
type="checkbox"
checked={checked}
disabled={isLiteralNa}
onChange={() => onNaPolicyToggle(token)}
style={styles.checkbox}
aria-label={`Treat ${label} as N/A`}
/>
{label}
</label>
);
})}
</div>
</div>
</div>
</div>
)}

{/* Non-greedy Algorithm Options */}
{selectedAlgorithm !== 'greedy' && (
<div style={styles.compactRow}>
Expand Down Expand Up @@ -659,6 +716,18 @@ const styles = {
borderRadius: '4px',
border: '1px solid #ddd',
},
naValuesContainer: {
padding: '12px 15px',
backgroundColor: '#fff',
borderRadius: '6px',
border: '1px solid #ddd',
},
naCheckboxGroup: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '16px',
marginTop: '8px',
},
checkboxGroup: {
display: 'flex',
flexDirection: 'column' as const,
Expand Down
Loading