← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/chromatogram-panel.tsx
22.2 KB · Sep 30, 2026 · 23:01 UTC
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactElement,
} from "react";
import { Button } from "../ui/button";
import { selectionContainsCoordinate } from "./selection";
import type {
ChromatogramBase,
SequenceChromatogram,
SequenceRecord,
SequenceSelection,
} from "./types";
const DEFAULT_WINDOW_BASES = 40;
const MAX_WINDOW_BASES = 100;
const MIN_WINDOW_BASES = 4;
const MAX_TRACE_POINTS = 2_048;
const TRACE_HEIGHT = 152;
const TRACE_PADDING = 10;
const CHANNELS: Array<{ base: ChromatogramBase; color: string }> = [
{ base: "A", color: "#16a34a" },
{ base: "C", color: "#3b82f6" },
{ base: "G", color: "var(--bio-token-text-primary, #334155)" },
{ base: "T", color: "#ef4444" },
];
type SignalPoint = { sample: number; value: number };
export type ChromatogramViewState = {
/** Requested call count; the readable window may be smaller on a narrow screen. */
basesPerWindow: number;
/** First displayed base in the original read, using 1-based coordinates. */
firstBase: number;
};
type ChromatogramPanelProps = {
/** The untransformed record: trace peaks use original source coordinates. */
record: SequenceRecord;
/** Increment when restoring a saved view, even if its window is unchanged. */
restorationEpoch?: number;
selection?: SequenceSelection;
view?: ChromatogramViewState;
onSelectRange: (start: number, end: number) => void;
onViewChange?: (view: ChromatogramViewState) => void;
};
export function ChromatogramPanel({
record,
restorationEpoch,
selection,
view,
onSelectRange,
onViewChange,
}: ChromatogramPanelProps): ReactElement | null {
if (record.chromatogram == null) return null;
return (
<ChromatogramReadPanel
chromatogram={record.chromatogram}
key={record.id}
onSelectRange={onSelectRange}
onViewChange={onViewChange}
record={record}
restorationEpoch={restorationEpoch}
selection={selection}
view={view}
/>
);
}
function ChromatogramReadPanel({
chromatogram,
record,
restorationEpoch,
selection,
view,
onSelectRange,
onViewChange,
}: ChromatogramPanelProps & {
chromatogram: SequenceChromatogram;
}): ReactElement {
const baseCount = Math.min(
record.sequence.length,
chromatogram.peakLocations.length,
);
const activeSelection =
selection?.recordId === record.id ? selection : undefined;
const selectedStart = activeSelection?.start;
const selectedEnd = activeSelection?.end;
const [localView, setLocalView] = useState<ChromatogramViewState>({
basesPerWindow: DEFAULT_WINDOW_BASES,
firstBase: Math.max(1, (selectedStart ?? 1) - DEFAULT_WINDOW_BASES / 2),
});
const currentView = view ?? localView;
const [focusedBase, setFocusedBase] = useState(selectedStart ?? 1);
const [plotWidth, setPlotWidth] = useState(800);
const plotRef = useRef<HTMLDivElement>(null);
const baseButtons = useRef(new Map<number, HTMLButtonElement>());
const selectionAnchor = useRef<number | null>(null);
const pendingFocus = useRef<number | null>(null);
const emittedSelection = useRef<{ end: number; start: number } | null>(null);
const previousInput = useRef({
chromatogram,
restorationEpoch,
selectedEnd,
selectedStart,
viewBasesPerWindow: view?.basesPerWindow,
viewFirstBase: view?.firstBase,
});
// Reserve readable space per call instead of shrinking letters to fit a read.
const maximumVisibleBases = Math.min(
MAX_WINDOW_BASES,
Math.max(1, Math.floor(plotWidth / 22)),
baseCount,
);
const windowCapacity = Math.min(
currentView.basesPerWindow,
maximumVisibleBases,
);
const maximumStart = Math.max(1, baseCount);
const windowStart = Math.max(
1,
Math.min(currentView.firstBase, maximumStart),
);
const visibleBaseCount = Math.min(
windowCapacity,
baseCount - windowStart + 1,
);
const windowEnd = Math.min(baseCount, windowStart + visibleBaseCount - 1);
const tabStopBase =
focusedBase >= windowStart && focusedBase <= windowEnd
? focusedBase
: windowStart;
useEffect(() => {
const plot = plotRef.current;
if (plot == null || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(([entry]) => {
if (entry != null && entry.contentRect.width > 0) {
setPlotWidth(entry.contentRect.width);
}
});
observer.observe(plot);
return () => observer.disconnect();
}, []);
useEffect(() => {
const sourceChanged = previousInput.current.chromatogram !== chromatogram;
const selectionChanged =
previousInput.current.selectedStart !== selectedStart ||
previousInput.current.selectedEnd !== selectedEnd;
const explicitViewChanged =
view != null &&
(previousInput.current.viewFirstBase !== view.firstBase ||
previousInput.current.viewBasesPerWindow !== view.basesPerWindow ||
previousInput.current.restorationEpoch !== restorationEpoch);
previousInput.current = {
chromatogram,
restorationEpoch,
selectedEnd,
selectedStart,
viewBasesPerWindow: view?.basesPerWindow,
viewFirstBase: view?.firstBase,
};
if (sourceChanged) {
selectionAnchor.current = null;
emittedSelection.current = null;
if (view == null) {
setLocalView({
basesPerWindow: DEFAULT_WINDOW_BASES,
firstBase: Math.max(
1,
(selectedStart ?? 1) - DEFAULT_WINDOW_BASES / 2,
),
});
setFocusedBase(selectedStart ?? 1);
return;
}
}
if (!selectionChanged) return;
const fromPanel =
emittedSelection.current != null &&
emittedSelection.current?.start === selectedStart &&
emittedSelection.current?.end === selectedEnd;
emittedSelection.current = null;
if (fromPanel) return;
selectionAnchor.current = selectedStart ?? null;
if (selectedStart == null) return;
const coordinate = Math.max(1, Math.min(baseCount, selectedStart));
setFocusedBase(coordinate);
// An explicit view or restore may intentionally leave the selection offscreen.
if (explicitViewChanged) return;
if (coordinate >= windowStart && coordinate <= windowEnd) return;
const next = {
...currentView,
firstBase: Math.max(1, coordinate - Math.floor(windowCapacity / 2)),
};
if (view == null) setLocalView(next);
onViewChange?.(next);
}, [
baseCount,
chromatogram,
currentView,
onViewChange,
restorationEpoch,
selectedEnd,
selectedStart,
view,
windowCapacity,
windowEnd,
windowStart,
]);
useEffect(() => {
if (pendingFocus.current == null) return;
const button = baseButtons.current.get(pendingFocus.current);
if (button == null) return;
button.focus();
pendingFocus.current = null;
}, [focusedBase, windowEnd, windowStart]);
const sampleStart =
windowStart <= 1
? 0
: Math.floor(
(chromatogram.peakLocations[windowStart - 2] +
chromatogram.peakLocations[windowStart - 1]) /
2,
);
const sampleEnd =
windowEnd >= baseCount
? chromatogram.sampleCount - 1
: Math.ceil(
(chromatogram.peakLocations[windowEnd - 1] +
chromatogram.peakLocations[windowEnd]) /
2,
);
const signal = useMemo(() => {
const pointBudget = Math.max(
32,
Math.min(MAX_TRACE_POINTS, Math.round(plotWidth) * 2),
);
const traces = CHANNELS.map(({ base, color }) => ({
base,
color,
points: sampleSignal(
chromatogram.channels[base],
sampleStart,
sampleEnd,
pointBudget,
),
}));
let minimum = 0;
let maximum = 0;
for (const { points } of traces) {
for (const { value } of points) {
minimum = Math.min(minimum, value);
maximum = Math.max(maximum, value);
}
}
return {
downsampled: sampleEnd - sampleStart + 1 > pointBudget,
maximum,
minimum,
traces,
};
}, [chromatogram, plotWidth, sampleEnd, sampleStart]);
if (baseCount === 0 || chromatogram.sampleCount === 0) {
return (
<p className="text-sm text-token-text-secondary" role="status">
No called bases with trace samples are available in this record.
</p>
);
}
const xForSample = (sample: number): number =>
Math.max(
0,
Math.min(
1,
(sample - sampleStart) / Math.max(1, sampleEnd - sampleStart),
),
) *
(plotWidth - 24) +
12;
const yForSignal = (value: number): number =>
TRACE_HEIGHT -
TRACE_PADDING -
((value - signal.minimum) / Math.max(1, signal.maximum - signal.minimum)) *
(TRACE_HEIGHT - 2 * TRACE_PADDING);
const calls = Array.from({ length: visibleBaseCount }, (_, index) => {
const coordinate = windowStart + index;
const base = record.sequence[coordinate - 1];
const sample = chromatogram.peakLocations[coordinate - 1];
return {
base,
color:
CHANNELS.find((channel) => channel.base === base)?.color ??
"var(--bio-token-text-secondary, #64748b)",
coordinate,
quality: chromatogram.quality?.[coordinate - 1],
sample,
selected:
activeSelection != null &&
selectionContainsCoordinate(activeSelection, coordinate),
x: xForSample(sample),
};
});
const hasQuality =
chromatogram.quality?.some((value) => value != null) ?? false;
const qualityLabel =
chromatogram.qualityEncoding === "phred"
? "Phred quality"
: "Source confidence";
function selectBase(coordinate: number, extend: boolean): void {
const anchor = extend
? (selectionAnchor.current ?? selectedStart ?? coordinate)
: coordinate;
selectionAnchor.current = anchor;
setFocusedBase(coordinate);
const start = Math.min(anchor, coordinate);
const end = Math.max(anchor, coordinate);
emittedSelection.current = { end, start };
onSelectRange(start, end);
}
function changeView(next: ChromatogramViewState): void {
if (view == null) setLocalView(next);
onViewChange?.(next);
}
function setFirstBase(firstBase: number): void {
changeView({ ...currentView, firstBase });
}
function setRequestedBaseCount(basesPerWindow: number): void {
changeView({ ...currentView, basesPerWindow });
}
function navigateBase(
event: KeyboardEvent<HTMLButtonElement>,
coordinate: number,
): void {
let next: number;
switch (event.key) {
case "ArrowLeft":
next = coordinate - 1;
break;
case "ArrowRight":
next = coordinate + 1;
break;
case "Home":
next = 1;
break;
case "End":
next = baseCount;
break;
case "PageUp":
next = coordinate - windowCapacity;
break;
case "PageDown":
next = coordinate + windowCapacity;
break;
default:
return;
}
event.preventDefault();
next = Math.max(1, Math.min(baseCount, next));
if (next === coordinate) return;
if (event.shiftKey) {
selectionAnchor.current ??= selectedStart ?? coordinate;
selectBase(next, true);
}
if (next < windowStart) setFirstBase(next);
else if (next > windowEnd) setFirstBase(next - windowCapacity + 1);
pendingFocus.current = next;
setFocusedBase(next);
}
return (
<section
aria-label="Chromatogram"
className="min-w-0 rounded-xl border border-token-border bg-token-main-surface-primary p-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-token-text-primary">
Chromatogram
</h2>
<p className="mt-0.5 text-xs text-token-text-secondary">
{chromatogram.format.toUpperCase()} · Original read orientation
</p>
</div>
<div
aria-label="Trace channels"
className="flex gap-3 text-xs font-medium"
>
{CHANNELS.map(({ base, color }) => (
<span className="inline-flex items-center gap-1.5" key={base}>
<span
aria-hidden="true"
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: color }}
/>
{base}
</span>
))}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<p
className="text-xs tabular-nums text-token-text-secondary"
aria-live="polite"
>
Bases {windowStart.toLocaleString()}–{windowEnd.toLocaleString()} of{" "}
{baseCount.toLocaleString()}
</p>
<div className="flex flex-wrap items-center gap-1">
<Button
aria-label="Previous chromatogram window"
disabled={windowStart === 1}
onClick={() =>
setFirstBase(Math.max(1, windowStart - windowCapacity))
}
type="button"
>
Previous
</Button>
<Button
aria-label="Next chromatogram window"
disabled={windowEnd === baseCount}
onClick={() =>
setFirstBase(Math.min(maximumStart, windowStart + windowCapacity))
}
type="button"
>
Next
</Button>
<Button
aria-label="Zoom out chromatogram"
className="ml-1"
disabled={windowCapacity >= maximumVisibleBases}
onClick={() =>
setRequestedBaseCount(
Math.min(MAX_WINDOW_BASES, windowCapacity * 2),
)
}
type="button"
>
−
</Button>
<Button
aria-label="Zoom in chromatogram"
disabled={windowCapacity <= Math.min(MIN_WINDOW_BASES, baseCount)}
onClick={() =>
setRequestedBaseCount(
Math.max(MIN_WINDOW_BASES, Math.floor(windowCapacity / 2)),
)
}
type="button"
>
+
</Button>
</div>
</div>
<figure
aria-label={`${record.sourceLabel} chromatogram, bases ${windowStart} to ${windowEnd}, four signal channels in original read orientation`}
className="m-0 mt-3 min-w-0"
>
<div className="relative min-w-0 pt-[72px]" ref={plotRef}>
<div
aria-label="Called bases"
className="absolute inset-x-0 top-0 h-[72px]"
>
{calls.map(({ base, color, coordinate, quality, selected, x }) => {
const confidenceDetails =
chromatogram.baseConfidences == null
? ""
: CHANNELS.map(
({ base: channel }) =>
`${channel} confidence ${chromatogram.baseConfidences?.[channel][coordinate - 1]}`,
).join(", ");
const label = [
`Base ${coordinate}, ${base}`,
quality == null ? null : `${qualityLabel} ${quality}`,
confidenceDetails || null,
]
.filter((part) => part != null)
.join(", ");
return (
<button
aria-label={label}
aria-pressed={selected}
className={`absolute top-0 flex h-12 -translate-x-1/2 flex-col items-center justify-center rounded-md border px-1 font-mono text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 ${selected ? "border-sky-500/50 bg-sky-500/10" : "border-transparent hover:bg-token-main-surface-secondary"}`}
data-coordinate={coordinate}
key={coordinate}
onClick={(event) => selectBase(coordinate, event.shiftKey)}
onFocus={() => setFocusedBase(coordinate)}
onKeyDown={(event) => navigateBase(event, coordinate)}
ref={(element) => {
if (element == null) baseButtons.current.delete(coordinate);
else baseButtons.current.set(coordinate, element);
}}
style={{ color, left: `${(x / plotWidth) * 100}%` }}
tabIndex={coordinate === tabStopBase ? 0 : -1}
title={label}
type="button"
>
<span aria-hidden="true">{base}</span>
{hasQuality ? (
<span
aria-hidden="true"
className="mt-0.5 text-[10px] font-normal tabular-nums text-token-text-secondary"
>
{quality ?? "—"}
</span>
) : null}
{coordinate === windowStart ||
coordinate === windowEnd ||
coordinate % 10 === 0 ? (
<span
aria-hidden="true"
className="absolute top-[52px] text-[10px] font-normal tabular-nums text-token-text-secondary"
>
{coordinate.toLocaleString()}
</span>
) : null}
</button>
);
})}
</div>
<svg
aria-hidden="true"
className="block w-full cursor-crosshair overflow-hidden"
focusable="false"
height={TRACE_HEIGHT}
onClick={(event) => {
const bounds = event.currentTarget.getBoundingClientRect();
if (bounds.width === 0) return;
const x =
((event.clientX - bounds.left) / bounds.width) * plotWidth;
const nearest = calls.reduce((closest, call) =>
Math.abs(call.x - x) < Math.abs(closest.x - x) ? call : closest,
);
selectBase(nearest.coordinate, event.shiftKey);
}}
preserveAspectRatio="none"
viewBox={`0 0 ${plotWidth} ${TRACE_HEIGHT}`}
>
{calls
.filter(({ selected }) => selected)
.map(({ coordinate, sample }) => {
const previous =
chromatogram.peakLocations[coordinate - 2] ?? sampleStart;
const next =
chromatogram.peakLocations[coordinate] ?? sampleEnd;
const left = xForSample((previous + sample) / 2);
const right = xForSample((sample + next) / 2);
return (
<rect
fill="#0ea5e9"
height={TRACE_HEIGHT}
key={coordinate}
opacity="0.08"
width={Math.max(1, right - left)}
x={left}
/>
);
})}
<line
stroke="var(--bio-token-text-secondary, #64748b)"
strokeOpacity="0.2"
x1="0"
x2={plotWidth}
y1={yForSignal(0)}
y2={yForSignal(0)}
/>
{signal.traces.map(({ base, color, points }) => (
<path
d={points
.map(
({ sample, value }, index) =>
`${index === 0 ? "M" : "L"}${xForSample(sample).toFixed(2)},${yForSignal(value).toFixed(2)}`,
)
.join(" ")}
data-channel={base}
fill="none"
key={base}
stroke={color}
strokeLinejoin="round"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
<figcaption className="mt-2 flex flex-wrap justify-between gap-2 text-[11px] text-token-text-secondary">
<span>Signal intensity · auto-scaled per window</span>
<span>
{hasQuality
? `${qualityLabel} below calls`
: "No called-base confidence provided"}
</span>
{signal.downsampled ? (
<span>Trace display uses a min/max envelope</span>
) : null}
</figcaption>
</figure>
<label className="mt-3 flex items-center gap-3 text-[11px] text-token-text-secondary">
<span>Position</span>
<input
aria-label="Chromatogram window start"
aria-valuetext={`First displayed base ${windowStart} of ${baseCount}`}
className="min-w-0 flex-1 accent-sky-600"
disabled={maximumStart === 1}
max={maximumStart}
min={1}
onChange={(event) => setFirstBase(Number(event.target.value))}
step={1}
type="range"
value={windowStart}
/>
</label>
<p className="mt-2 text-[11px] text-token-text-secondary">
Select a call or peak. Shift extends a selection; arrow keys move
between calls. Coordinates are 1-based in the original read.
</p>
</section>
);
}
/** Keep each bucket's real minimum and maximum in sample order, including endpoints. */
function sampleSignal(
values: Array<number>,
start: number,
end: number,
maximumPoints: number,
): Array<SignalPoint> {
const first = Math.max(0, start);
const last = Math.min(values.length - 1, end);
if (last < first) return [];
const count = last - first + 1;
if (count <= maximumPoints) {
return Array.from({ length: count }, (_, index) => ({
sample: first + index,
value: values[first + index],
}));
}
const points: Array<SignalPoint> = [{ sample: first, value: values[first] }];
const bucketSize = Math.ceil(
(count - 2) / Math.floor((maximumPoints - 2) / 2),
);
for (
let bucketStart = first + 1;
bucketStart < last;
bucketStart += bucketSize
) {
const bucketEnd = Math.min(last, bucketStart + bucketSize);
let minimum = bucketStart;
let maximum = bucketStart;
for (let sample = bucketStart + 1; sample < bucketEnd; sample += 1) {
if (values[sample] < values[minimum]) minimum = sample;
if (values[sample] > values[maximum]) maximum = sample;
}
const earlier = Math.min(minimum, maximum);
const later = Math.max(minimum, maximum);
points.push({ sample: earlier, value: values[earlier] });
if (later !== earlier) points.push({ sample: later, value: values[later] });
}
points.push({ sample: last, value: values[last] });
return points;
}
SHA-256: 09b5d8f2e83558c840db0aae2914ea74338d981c867206291b5930d856da2d36