← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/msa/rows-panel.tsx
16.9 KB · Sep 30, 2026 · 23:01 UTC
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import type {
MsaDocument,
MsaRowMetrics,
MsaRowSortDirection,
MsaRowSortKey,
} from "./types";
const ROW_CONTROL_HEIGHT_PX = 44;
const ROW_CONTROL_OVERSCAN = 8;
type RowPanelMetrics = {
height: number;
scrollTop: number;
};
const EMPTY_METRICS: RowPanelMetrics = {
height: 0,
scrollTop: 0,
};
export function MsaRowsPanel({
document,
onSelectRows,
onSortDirectionChange,
onSortKeyChange,
onToggleRow,
referenceKind,
rowMetricsById,
selectedRowIds,
sortDirection,
sortKey,
}: {
document: MsaDocument;
onSelectRows: (rowIds: Array<string>) => void;
onSortDirectionChange: (direction: MsaRowSortDirection) => void;
onSortKeyChange: (key: MsaRowSortKey) => void;
onToggleRow: (rowId: string) => void;
referenceKind: "anchor" | "consensus" | "none";
rowMetricsById: Record<string, MsaRowMetrics | undefined>;
selectedRowIds: Array<string>;
sortDirection: MsaRowSortDirection;
sortKey: MsaRowSortKey;
}): React.ReactElement {
const intl = useIntl();
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(
null,
);
const [metrics, setMetrics] = useState<RowPanelMetrics>(EMPTY_METRICS);
const animationFrameRef = useRef<number | null>(null);
const documentRowIds = useMemo(
() => new Set(document.rows.map((row) => row.id)),
[document.rows],
);
const validSelectedRowIds = useMemo(() => {
const retained = new Set<string>();
for (const rowId of selectedRowIds) {
if (documentRowIds.has(rowId)) {
retained.add(rowId);
}
}
return retained;
}, [documentRowIds, selectedRowIds]);
const updateMetrics = useCallback((node: HTMLDivElement): void => {
setMetrics({ height: node.clientHeight, scrollTop: node.scrollTop });
}, []);
const setScrollContainerRef = useCallback(
(node: HTMLDivElement | null): void => {
setScrollContainer(node);
if (node != null) {
updateMetrics(node);
}
},
[updateMetrics],
);
useEffect(() => {
if (scrollContainer == null) {
return;
}
const scheduleMetricsUpdate = (): void => {
if (animationFrameRef.current != null) {
return;
}
animationFrameRef.current = scheduleFrame(() => {
animationFrameRef.current = null;
updateMetrics(scrollContainer);
});
};
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleMetricsUpdate);
observer?.observe(scrollContainer);
scrollContainer.addEventListener("scroll", scheduleMetricsUpdate, {
passive: true,
});
return (): void => {
if (animationFrameRef.current != null) {
cancelFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
observer?.disconnect();
scrollContainer.removeEventListener("scroll", scheduleMetricsUpdate);
};
}, [scrollContainer, updateMetrics]);
const sortedRows = useMemo(() => {
const sourceOrder = new Map(
document.rows.map((row, index) => [row.id, index]),
);
return [...document.rows].sort((left, right) => {
const leftMetric = rowMetricsById[left.id];
const rightMetric = rowMetricsById[right.id];
let comparison = 0;
switch (sortKey) {
case "coverage":
comparison = compareNullableNumber(
leftMetric?.coverageToReference,
rightMetric?.coverageToReference,
);
break;
case "identity":
comparison = compareNullableNumber(
leftMetric?.identityToReference,
rightMetric?.identityToReference,
);
break;
case "label":
comparison = left.label.localeCompare(right.label);
break;
case "length":
comparison = left.ungappedLength - right.ungappedLength;
break;
case "mismatches":
comparison = compareNullableNumber(
leftMetric?.mismatchCountToReference,
rightMetric?.mismatchCountToReference,
);
break;
case "source":
comparison =
(sourceOrder.get(left.id) ?? 0) - (sourceOrder.get(right.id) ?? 0);
break;
}
if (comparison === 0) {
comparison = left.label.localeCompare(right.label);
}
return sortDirection === "asc" ? comparison : -comparison;
});
}, [document.rows, rowMetricsById, sortDirection, sortKey]);
const { end, start } = useMemo(() => {
const viewportHeight =
metrics.height > 0 ? metrics.height : ROW_CONTROL_HEIGHT_PX * 12;
const visibleStart = Math.max(
0,
Math.floor(metrics.scrollTop / ROW_CONTROL_HEIGHT_PX),
);
const visibleCount = Math.max(
1,
Math.ceil(viewportHeight / ROW_CONTROL_HEIGHT_PX),
);
return {
end: Math.min(
sortedRows.length,
visibleStart + visibleCount + ROW_CONTROL_OVERSCAN,
),
start: Math.max(0, visibleStart - ROW_CONTROL_OVERSCAN),
};
}, [metrics.height, metrics.scrollTop, sortedRows.length]);
const visibleRows = sortedRows.slice(start, end);
const selectedRows = document.rows.filter((row) =>
validSelectedRowIds.has(row.id),
);
const allSelected = validSelectedRowIds.size === document.rows.length;
const toggleSelection = useCallback(
(rowId: string): void => {
const next = new Set(validSelectedRowIds);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
onSelectRows([...next]);
},
[onSelectRows, validSelectedRowIds],
);
const selectAllRows = useCallback((): void => {
onSelectRows(document.rows.map((row) => row.id));
}, [document.rows, onSelectRows]);
const clearSelection = useCallback((): void => {
onSelectRows([]);
}, [onSelectRows]);
const hideSelectedRows = useCallback((): void => {
for (const row of selectedRows) {
if (!row.hidden) {
onToggleRow(row.id);
}
}
}, [onToggleRow, selectedRows]);
const showAllRows = useCallback((): void => {
for (const row of document.rows) {
if (row.hidden) {
onToggleRow(row.id);
}
}
}, [document.rows, onToggleRow]);
return (
<aside
aria-label={intl.formatMessage({
id: "codex.filePreview.msa.rowVisibilityControls",
defaultMessage: "MSA row visibility controls",
description:
"Accessible label for the MSA row visibility controls panel.",
})}
className="flex min-h-64 min-w-0 flex-col text-xs"
>
<div className="mb-2 shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-token-text-primary">
<FormattedMessage
id="codex.filePreview.msa.rows"
defaultMessage="Rows"
description="Heading for the MSA sequence-row visibility panel."
/>
</div>
<span className="text-[11px] text-token-text-tertiary">
<FormattedMessage
id="codex.filePreview.msa.selectedRows"
defaultMessage="{count, number} selected"
description="Row-panel summary showing how many rows are selected."
values={{ count: validSelectedRowIds.size }}
/>
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-1">
<PanelButton onClick={allSelected ? clearSelection : selectAllRows}>
{allSelected ? (
<FormattedMessage
id="codex.filePreview.msa.clearRowSelection"
defaultMessage="Clear selection"
description="Button label for clearing selected MSA rows."
/>
) : (
<FormattedMessage
id="codex.filePreview.msa.selectAllRows"
defaultMessage="Select all"
description="Button label for selecting every MSA row."
/>
)}
</PanelButton>
<PanelButton
disabled={selectedRows.length === 0}
onClick={hideSelectedRows}
>
<FormattedMessage
id="codex.filePreview.msa.hideSelectedRows"
defaultMessage="Hide selected"
description="Button label for hiding currently selected MSA rows."
/>
</PanelButton>
<PanelButton onClick={showAllRows}>
<FormattedMessage
id="codex.filePreview.msa.showAllRows"
defaultMessage="Show all"
description="Button label for showing every MSA row."
/>
</PanelButton>
<PanelButton
onClick={() =>
onSortDirectionChange(sortDirection === "asc" ? "desc" : "asc")
}
>
<FormattedMessage
id="codex.filePreview.msa.toggleRowSortDirection"
defaultMessage="{direction} sort"
description="Button label that toggles the row metrics sort direction."
values={{
direction: sortDirection === "asc" ? "Ascending" : "Descending",
}}
/>
</PanelButton>
</div>
<label className="mt-2 flex flex-col gap-1 text-[11px] text-token-text-secondary">
<span>
<FormattedMessage
id="codex.filePreview.msa.sortRowsBy"
defaultMessage="Sort rows by"
description="Label for the MSA row sorting control."
/>
</span>
<select
className="h-7 rounded-md border border-token-border bg-token-input-background px-2 text-xs text-token-text-primary outline-none focus:border-token-focus-border"
onChange={(event) =>
onSortKeyChange(event.target.value as MsaRowSortKey)
}
value={sortKey}
>
<option value="source">
{intl.formatMessage({
id: "codex.filePreview.msa.rowSort.source",
defaultMessage: "Source order",
description: "MSA row sorting option for source-file order.",
})}
</option>
<option value="label">
{intl.formatMessage({
id: "codex.filePreview.msa.rowSort.label",
defaultMessage: "Label / accession",
description: "MSA row sorting option for row labels.",
})}
</option>
<option value="length">
{intl.formatMessage({
id: "codex.filePreview.msa.rowSort.length",
defaultMessage: "Ungapped length",
description: "MSA row sorting option for row length.",
})}
</option>
<option value="identity">
{intl.formatMessage({
id: "codex.filePreview.msa.rowSort.identity",
defaultMessage: "Identity to reference",
description:
"MSA row sorting option for identity to the active reference.",
})}
</option>
<option value="mismatches">
{intl.formatMessage({
id: "codex.filePreview.msa.rowSort.mismatches",
defaultMessage: "Mismatch count",
description:
"MSA row sorting option for mismatch count against the reference.",
})}
</option>
<option value="coverage">
{intl.formatMessage({
id: "codex.filePreview.msa.rowSort.coverage",
defaultMessage: "Coverage to reference",
description: "MSA row sorting option for reference coverage.",
})}
</option>
</select>
</label>
{referenceKind === "none" ? (
<div className="bg-token-main-surface-secondary mt-2 rounded-md px-2 py-1 text-[11px] text-token-text-tertiary">
<FormattedMessage
id="codex.filePreview.msa.rowMetricsNeedReference"
defaultMessage="Identity, mismatch, and coverage metrics become available after choosing a representative or anchor reference."
description="Hint shown when reference-relative row metrics cannot yet be computed."
/>
</div>
) : null}
</div>
<p className="mb-2 text-[11px] text-token-text-secondary">
Select rows here to analyze, edit, or export them. Visibility controls
change the display without deleting sequences.
</p>
<div
className="max-h-96 min-h-0 flex-1 overflow-auto"
ref={setScrollContainerRef}
>
<div
className="relative"
style={{ height: sortedRows.length * ROW_CONTROL_HEIGHT_PX }}
>
<div
className="absolute inset-x-0 top-0"
style={{
transform: `translateY(${start * ROW_CONTROL_HEIGHT_PX}px)`,
}}
>
{visibleRows.map((row) => {
const metric = rowMetricsById[row.id];
return (
<div
className="hover:bg-token-main-surface-secondary flex h-11 items-start gap-2 rounded px-1 py-1 text-token-text-secondary"
key={row.id}
>
<input
aria-label={intl.formatMessage(
{
id: "codex.filePreview.msa.selectSpecificRow",
defaultMessage: "Select row {label}",
description:
"Accessible label for selecting an MSA row inside the bulk row panel.",
},
{ label: row.label },
)}
checked={validSelectedRowIds.has(row.id)}
onChange={() => toggleSelection(row.id)}
type="checkbox"
/>
<input
aria-label={intl.formatMessage(
{
id: "codex.filePreview.msa.toggleSpecificRow",
defaultMessage: "Show row {label}",
description:
"Accessible label for toggling MSA row visibility.",
},
{ label: row.label },
)}
checked={!row.hidden}
onChange={() => onToggleRow(row.id)}
type="checkbox"
/>
<div className="min-w-0 flex-1">
<div
className="truncate font-medium text-token-text-primary"
title={row.label}
>
{row.label}
</div>
<div className="truncate text-[10px] text-token-text-tertiary">
{formatMetrics(metric, row.ungappedLength)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</aside>
);
}
function PanelButton({
children,
disabled = false,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick: () => void;
}): React.ReactElement {
return (
<button
className="h-7 rounded-md border border-token-border bg-token-main-surface-primary px-2 text-[11px] text-token-text-secondary disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={onClick}
type="button"
>
{children}
</button>
);
}
function formatMetrics(
metric: MsaRowMetrics | undefined,
fallbackLength: number,
): string {
if (metric == null) {
return `Length ${fallbackLength}`;
}
const identity =
metric.identityToReference == null
? "identity n/a"
: `identity ${Math.round(metric.identityToReference * 100)}%`;
const coverage =
metric.coverageToReference == null
? "coverage n/a"
: `coverage ${Math.round(metric.coverageToReference * 100)}%`;
const mismatches =
metric.mismatchCountToReference == null
? "mismatches n/a"
: `${metric.mismatchCountToReference} mismatch${
metric.mismatchCountToReference === 1 ? "" : "es"
}`;
return `${identity} · ${coverage} · ${mismatches} · len ${metric.ungappedLength}`;
}
function compareNullableNumber(
left: number | null | undefined,
right: number | null | undefined,
): number {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}
return left - right;
}
function scheduleFrame(callback: FrameRequestCallback): number {
if (typeof requestAnimationFrame === "function") {
return requestAnimationFrame(callback);
}
return window.setTimeout(() => callback(performance.now()), 16);
}
function cancelFrame(handle: number): void {
if (typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(handle);
return;
}
window.clearTimeout(handle);
}
SHA-256: 3aaf15bfabfec7abc975f5a492d27bc5ed6bcedbbe5096248e1dbbbe1279aae2