Skip to content

Commit a4fb707

Browse files
eurunuelaclaude
andauthored
feat: Add component sorting by any metric in metrics.tsv (#125)
* feat: Add component sorting by any metric via clickable table headers (#124) - Clicking a column header sorts the component table by that metric (desc → asc → clear) - Active sort column is highlighted in blue with ↑/↓ indicator - Arrow-key figure navigation follows the same sorted order - Sort preference persists across page reloads via localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Show all metrics in table and prevent scroll-jump on arrow key navigation - Replace DISPLAY_COLUMNS whitelist with PRIORITY_COLUMNS that includes all labeled metrics (normalized variance explained, countsigFT2, countsigFS0, signal-noise_p, optimal sign, variance explained rank, rationale); any additional TSV columns are appended after the priority list - Replace row.scrollIntoView() with container.scrollTo() so arrow-key navigation only scrolls within the metrics table, not the whole page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Address Copilot review comments on sorting feature - Fix string-column sort: use localeCompare instead of subtraction to avoid NaN when sorting Component, classification, tags, etc. - Fix null handling: push missing values to end instead of treating as 0 - Fix cleared-sort table order: sortedIndices now returns original TSV order when no sort column is active (keyboard arrow navigation still uses pieData grouping as before) - Fix sortedIndices empty-array falsy bug: guard with Array.isArray + length check before using sortedIndices in table body - Fix accessibility: move sort onClick from <th> to a <button> inside each header; add aria-sort attribute to active column Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 79252c9 commit a4fb707

2 files changed

Lines changed: 169 additions & 58 deletions

File tree

src/Plots/ComponentTable.js

Lines changed: 97 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -46,56 +46,62 @@ function getColumnLabel(key) {
4646
"classification_tags": "Tags",
4747
"kappa rank": "κ Rank",
4848
"rho rank": "ρ Rank",
49+
"variance explained rank": "VE Rank",
4950
"rationale": "Rationale",
5051
};
5152
return labels[key] || key;
5253
}
5354

54-
// Columns to display (in order)
55-
const DISPLAY_COLUMNS = [
55+
// Preferred column order — known columns first, then any extras from the TSV
56+
const PRIORITY_COLUMNS = [
5657
"Component",
5758
"kappa",
5859
"rho",
5960
"variance explained",
61+
"normalized variance explained",
6062
"kappa rank",
6163
"rho rank",
64+
"variance explained rank",
6265
"dice_FT2",
6366
"dice_FS0",
67+
"countsigFT2",
68+
"countsigFS0",
6469
"signal-noise_t",
70+
"signal-noise_p",
71+
"optimal sign",
6572
"classification",
6673
"classification_tags",
74+
"rationale",
6775
];
6876

69-
function ComponentTable({ data, selectedIndex, onRowClick, classifications, isDark = false, isCollapsed = false, onToggleCollapse }) {
77+
function ComponentTable({ data, selectedIndex, onRowClick, classifications, isDark = false, isCollapsed = false, onToggleCollapse, sortColumn = '', sortDirection = 'desc', onSort, sortedIndices }) {
7078
const selectedRowRef = useRef(null);
7179
const tableContainerRef = useRef(null);
7280

73-
// Scroll selected row into view when selection changes (only if not collapsed)
81+
// Scroll selected row into view within the table container only (don't scroll the page)
7482
useEffect(() => {
75-
if (isCollapsed) return; // Don't auto-scroll when collapsed
83+
if (isCollapsed) return;
7684
if (selectedRowRef.current && tableContainerRef.current) {
7785
const container = tableContainerRef.current;
7886
const row = selectedRowRef.current;
79-
80-
// Calculate if row is visible in the container
8187
const containerRect = container.getBoundingClientRect();
8288
const rowRect = row.getBoundingClientRect();
83-
84-
// Check if row is outside visible area
8589
if (rowRect.top < containerRect.top || rowRect.bottom > containerRect.bottom) {
86-
row.scrollIntoView({
87-
behavior: "smooth",
88-
block: "center",
89-
});
90+
const targetScrollTop =
91+
row.offsetTop - container.clientHeight / 2 + row.offsetHeight / 2;
92+
container.scrollTo({ top: targetScrollTop, behavior: "smooth" });
9093
}
9194
}
9295
}, [selectedIndex, isCollapsed]);
9396

94-
// Determine which columns exist in the data
97+
// Show all columns: priority-ordered known columns first, then any extras from the TSV
9598
const columns = useMemo(() => {
9699
if (!data?.length) return [];
97-
const availableKeys = Object.keys(data[0]);
98-
return DISPLAY_COLUMNS.filter((col) => availableKeys.includes(col));
100+
const availableKeys = new Set(Object.keys(data[0]));
101+
const priorityVisible = PRIORITY_COLUMNS.filter((col) => availableKeys.has(col));
102+
const prioritySet = new Set(priorityVisible);
103+
const extra = Object.keys(data[0]).filter((col) => !prioritySet.has(col));
104+
return [...priorityVisible, ...extra];
99105
}, [data]);
100106

101107
if (!data?.length) {
@@ -217,45 +223,91 @@ function ComponentTable({ data, selectedIndex, onRowClick, classifications, isDa
217223
<table style={{ width: '100%', fontSize: '13px', borderCollapse: "separate", borderSpacing: "0" }}>
218224
<thead>
219225
<tr>
220-
{columns.map((col) => (
221-
<th
222-
key={col}
223-
style={{
224-
padding: '12px',
225-
fontWeight: 600,
226-
whiteSpace: 'nowrap',
227-
textAlign: col === "Component" || col === "classification" || col === "classification_tags" ? 'left' : 'right',
228-
position: "sticky",
229-
top: 0,
230-
backgroundColor: headerBg,
231-
color: headerColor,
232-
zIndex: 10,
233-
}}
234-
>
235-
{getColumnLabel(col)}
236-
</th>
237-
))}
226+
{columns.map((col) => {
227+
const isActive = sortColumn === col;
228+
const textAlign = col === "Component" || col === "classification" || col === "classification_tags" ? 'left' : 'right';
229+
const ariaSort = isActive ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none';
230+
return (
231+
<th
232+
key={col}
233+
aria-sort={ariaSort}
234+
style={{
235+
padding: '12px',
236+
fontWeight: 600,
237+
whiteSpace: 'nowrap',
238+
textAlign,
239+
position: "sticky",
240+
top: 0,
241+
backgroundColor: headerBg,
242+
color: isActive ? (isDark ? '#60a5fa' : '#2563eb') : headerColor,
243+
zIndex: 10,
244+
userSelect: 'none',
245+
}}
246+
title={onSort ? `Sort by ${getColumnLabel(col)}` : undefined}
247+
>
248+
{onSort ? (
249+
<button
250+
type="button"
251+
onClick={() => onSort(col)}
252+
style={{
253+
width: '100%',
254+
display: 'flex',
255+
alignItems: 'center',
256+
justifyContent: textAlign === 'left' ? 'flex-start' : 'flex-end',
257+
gap: '4px',
258+
padding: 0,
259+
border: 'none',
260+
background: 'transparent',
261+
color: 'inherit',
262+
font: 'inherit',
263+
cursor: 'pointer',
264+
}}
265+
>
266+
<span>{getColumnLabel(col)}</span>
267+
{isActive && (
268+
<span style={{ fontSize: '10px' }} aria-hidden="true">
269+
{sortDirection === 'asc' ? '↑' : '↓'}
270+
</span>
271+
)}
272+
</button>
273+
) : (
274+
<>
275+
{getColumnLabel(col)}
276+
{isActive && (
277+
<span style={{ marginLeft: '4px', fontSize: '10px' }}>
278+
{sortDirection === 'asc' ? '↑' : '↓'}
279+
</span>
280+
)}
281+
</>
282+
)}
283+
</th>
284+
);
285+
})}
238286
</tr>
239287
</thead>
240288
<tbody>
241-
{data.map((row, index) => {
242-
const classification = getClassification(index);
289+
{((Array.isArray(sortedIndices) && sortedIndices.length === data.length)
290+
? sortedIndices
291+
: data.map((_, i) => i)
292+
).map((originalIdx) => {
293+
const row = data[originalIdx];
294+
const classification = getClassification(originalIdx);
243295
return (
244296
<tr
245-
key={row.Component || index}
246-
ref={index === selectedIndex ? selectedRowRef : null}
247-
onClick={() => onRowClick(index)}
248-
style={getRowStyle(index)}
297+
key={row?.Component || originalIdx}
298+
ref={originalIdx === selectedIndex ? selectedRowRef : null}
299+
onClick={() => onRowClick(originalIdx)}
300+
style={getRowStyle(originalIdx)}
249301
onMouseEnter={(e) => {
250-
if (index !== selectedIndex) {
302+
if (originalIdx !== selectedIndex) {
251303
const cells = e.currentTarget.querySelectorAll("td");
252304
cells.forEach((cell) => {
253305
cell.style.backgroundColor = hoverBg;
254306
});
255307
}
256308
}}
257309
onMouseLeave={(e) => {
258-
if (index !== selectedIndex) {
310+
if (originalIdx !== selectedIndex) {
259311
const cells = e.currentTarget.querySelectorAll("td");
260312
cells.forEach((cell) => {
261313
cell.style.backgroundColor = "transparent";
@@ -264,7 +316,7 @@ function ComponentTable({ data, selectedIndex, onRowClick, classifications, isDa
264316
}}
265317
>
266318
{columns.map((col, colIndex) => {
267-
const cellStyle = getCellStyle(index, colIndex, columns.length);
319+
const cellStyle = getCellStyle(originalIdx, colIndex, columns.length);
268320
if (col === "classification") {
269321
return (
270322
<td key={col} style={{ ...cellStyle, padding: '12px' }}>
@@ -290,7 +342,7 @@ function ComponentTable({ data, selectedIndex, onRowClick, classifications, isDa
290342
</td>
291343
);
292344
}
293-
const isSelected = index === selectedIndex;
345+
const isSelected = originalIdx === selectedIndex;
294346
return (
295347
<td
296348
key={col}
@@ -299,11 +351,10 @@ function ComponentTable({ data, selectedIndex, onRowClick, classifications, isDa
299351
padding: '12px',
300352
textAlign: col === "Component" || col === "classification_tags" ? 'left' : 'right',
301353
fontWeight: col === "Component" || col === "classification_tags" ? 500 : 400,
302-
// Use dark text on selected rows for contrast
303354
color: isSelected ? '#1f2937' : textPrimary,
304355
}}
305356
>
306-
{formatValue(row[col], col)}
357+
{formatValue(row?.[col], col)}
307358
</td>
308359
);
309360
})}

src/Plots/Plots.js

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ function Plots({ componentData, componentFigures, originalData, mixingMatrix, ni
5959
return saved !== 'false'; // Default to true unless explicitly set to false
6060
});
6161

62+
// Sort column and direction for component table / keyboard navigation
63+
const [sortColumn, setSortColumn] = useState(
64+
() => localStorage.getItem('rica-sort-column') || ''
65+
);
66+
const [sortDirection, setSortDirection] = useState(
67+
() => localStorage.getItem('rica-sort-direction') || 'desc'
68+
);
69+
6270
// Persist preferences to localStorage when they change
6371
useEffect(() => {
6472
localStorage.setItem('rica-keep-original-order', keepOriginalOrder.toString());
@@ -72,6 +80,14 @@ function Plots({ componentData, componentFigures, originalData, mixingMatrix, ni
7280
localStorage.setItem('rica-table-collapsed', isTableCollapsed.toString());
7381
}, [isTableCollapsed]);
7482

83+
useEffect(() => {
84+
localStorage.setItem('rica-sort-column', sortColumn);
85+
}, [sortColumn]);
86+
87+
useEffect(() => {
88+
localStorage.setItem('rica-sort-direction', sortDirection);
89+
}, [sortDirection]);
90+
7591
// Interactive views (time series + FFT) require only the mixing matrix.
7692
// The brain viewer additionally requires the NIfTI.
7793
const hasInteractiveViews = mixingMatrix?.data?.length > 0;
@@ -257,38 +273,78 @@ function Plots({ componentData, componentFigures, originalData, mixingMatrix, ni
257273
return pieData.findIndex((d) => d.originalIdx === selectedIndex);
258274
}, [pieData, selectedIndex]);
259275

276+
// Navigation order for keyboard arrow keys and component table display.
277+
// When a sortColumn is active, components are ordered by that metric.
278+
// Otherwise falls back to pieData order (classification-grouped, variance-desc).
279+
const navigationOrder = useMemo(() => {
280+
if (!sortColumn || !processedData.length) return pieData;
281+
const fullData = componentData?.[0] || [];
282+
return [...processedData.map((d, i) => ({ ...d, originalIdx: i }))].sort((a, b) => {
283+
const valA = fullData[a.originalIdx]?.[sortColumn] ?? null;
284+
const valB = fullData[b.originalIdx]?.[sortColumn] ?? null;
285+
if (valA === null && valB === null) return 0;
286+
if (valA === null) return 1;
287+
if (valB === null) return -1;
288+
if (typeof valA === 'string' || typeof valB === 'string') {
289+
return sortDirection === 'asc'
290+
? String(valA).localeCompare(String(valB))
291+
: String(valB).localeCompare(String(valA));
292+
}
293+
return sortDirection === 'asc' ? valA - valB : valB - valA;
294+
});
295+
}, [processedData, sortColumn, sortDirection, componentData, pieData]);
296+
297+
const sortedIndices = useMemo(
298+
() => sortColumn
299+
? navigationOrder.map((d) => d.originalIdx)
300+
: processedData.map((_, i) => i),
301+
[navigationOrder, sortColumn, processedData]
302+
);
303+
304+
// Handle column header click: desc → asc → clear
305+
const handleSort = useCallback((col) => {
306+
if (sortColumn === col) {
307+
if (sortDirection === 'asc') {
308+
setSortColumn('');
309+
} else {
310+
setSortDirection('asc');
311+
}
312+
} else {
313+
setSortColumn(col);
314+
setSortDirection('desc');
315+
}
316+
}, [sortColumn, sortDirection]);
317+
260318
// Keyboard shortcuts
261319
useHotkeys("a", () => handleNewSelection("accepted"), [handleNewSelection]);
262320
useHotkeys("r", () => handleNewSelection("rejected"), [handleNewSelection]);
263321

264322
useHotkeys(
265323
"left",
266324
() => {
267-
// Navigate using pie chart order (wraps around)
268-
if (pieData.length === 0) return;
269-
const currentPieIdx = pieData.findIndex((d) => d.originalIdx === selectedIndex);
270-
const newPieIdx = currentPieIdx <= 0 ? pieData.length - 1 : currentPieIdx - 1;
271-
const newOriginalIdx = pieData[newPieIdx].originalIdx;
325+
if (navigationOrder.length === 0) return;
326+
const currentIdx = navigationOrder.findIndex((d) => d.originalIdx === selectedIndex);
327+
const newIdx = currentIdx <= 0 ? navigationOrder.length - 1 : currentIdx - 1;
328+
const newOriginalIdx = navigationOrder[newIdx].originalIdx;
272329
setSelectedIndex(newOriginalIdx);
273330
setSelectedClassification(processedData[newOriginalIdx]?.classification || "accepted");
274331
findComponentImage(newOriginalIdx, processedData);
275332
},
276-
[selectedIndex, pieData, processedData, findComponentImage]
333+
[selectedIndex, navigationOrder, processedData, findComponentImage]
277334
);
278335

279336
useHotkeys(
280337
"right",
281338
() => {
282-
// Navigate using pie chart order (wraps around)
283-
if (pieData.length === 0) return;
284-
const currentPieIdx = pieData.findIndex((d) => d.originalIdx === selectedIndex);
285-
const newPieIdx = currentPieIdx >= pieData.length - 1 ? 0 : currentPieIdx + 1;
286-
const newOriginalIdx = pieData[newPieIdx].originalIdx;
339+
if (navigationOrder.length === 0) return;
340+
const currentIdx = navigationOrder.findIndex((d) => d.originalIdx === selectedIndex);
341+
const newIdx = currentIdx >= navigationOrder.length - 1 ? 0 : currentIdx + 1;
342+
const newOriginalIdx = navigationOrder[newIdx].originalIdx;
287343
setSelectedIndex(newOriginalIdx);
288344
setSelectedClassification(processedData[newOriginalIdx]?.classification || "accepted");
289345
findComponentImage(newOriginalIdx, processedData);
290346
},
291-
[selectedIndex, pieData, processedData, findComponentImage]
347+
[selectedIndex, navigationOrder, processedData, findComponentImage]
292348
);
293349

294350
// Save handler
@@ -614,6 +670,10 @@ function Plots({ componentData, componentFigures, originalData, mixingMatrix, ni
614670
isDark={isDark}
615671
isCollapsed={isTableCollapsed}
616672
onToggleCollapse={() => setIsTableCollapsed(!isTableCollapsed)}
673+
sortColumn={sortColumn}
674+
sortDirection={sortDirection}
675+
onSort={handleSort}
676+
sortedIndices={sortedIndices}
617677
/>
618678

619679
{/* External Regressors Correlation Heatmap (interactive) or static figure */}

0 commit comments

Comments
 (0)