import { useState, type ReactNode, useMemo, useCallback, memo, useEffect, useRef, type CSSProperties, } from 'react'; import cn from 'classnames'; import Button from './button'; import { Dropdown } from './dropdown-button'; import CopyToClipboard from './copy-to-clipboard'; import InfoList, { type InfoListItem } from './info-list'; import SequenceTools, { type SequenceToolName } from './sequence-tools'; import DownloadIcon from '../svg/download.svg'; import SpinnerIcon from '../svg/spinner.svg'; import aminoAcidsProps from './data/amino-acid-properties.json' with { type: 'json' }; import '../styles/components/sequence.scss'; type AminoAcidProperty = { name: string; aminoAcids: Set; colour: string; }; const aaProps: AminoAcidProperty[] = aminoAcidsProps.map((aaProp) => ({ ...aaProp, // Optimise for later lookup aminoAcids: new Set(aaProp.aminoAcids), })); const chunksOfTen = /(.{1,10})/g; const SequenceChunks = memo( ({ sequence, computeHighlights, }: { sequence: string; computeHighlights: boolean; }) => { const ref = useRef(null); const chunks = useMemo(() => sequence.match(chunksOfTen) || [], [sequence]); useEffect(() => { // Don't run until needed the first time if (!computeHighlights) { return; } // OK, we requested a highlight, compute them all at once for (const [index, chunk] of chunks.entries()) { const boxShadow: string[] = []; let xOffset = 0; // start at negative values to get closer to the letters let yOffset = -3; for (const { name, aminoAcids } of aaProps) { for (const letter of chunk) { if (aminoAcids.has(letter)) { boxShadow.push( `${xOffset}ch ${yOffset}px 0 1px var(--color-${name}, transparent)` ); } xOffset += 10; } yOffset += 2; xOffset = 0; } const node = ref.current?.children[index] as HTMLElement | undefined; if (node) { // Avoid React by setting directly on the nodes node?.style.setProperty('--box-shadow', boxShadow.join(', ')); } } }, [chunks, computeHighlights]); return (
{chunks.map((chunk, index) => ( {chunk} ))}
); } ); const visibleElement = (onClick: () => unknown) => ( ); type SequenceProps = { /** * The sequence */ sequence?: string; /** * The accession corresponding to the sequence */ accession?: string; /** * Action triggered when the "Show sequence" button is clicked. * This button will be displayed by default if no sequence is passed * down to the component. */ onShowSequence?: () => void; /** * Display option to show/hide the sequence. If no sequence is * provided and `onShowSequence` is defined, this defaults to "true" */ isCollapsible?: boolean; /** * If the sequence is loading, display a spinner in the button */ isLoading?: boolean; /** * Data to be displayed in an InfoData component above the sequence */ infoData?: InfoListItem[]; /** * The URL to download the isoform sequence */ downloadUrl?: string; /** * Callback which is fired when the BLAST button is clicked. If no callback * is provided then no BLAST button will be displayed. */ onBlastClick?: () => void; /** Callback which is fired when the Add button is clicked. If no callback * is provided then no Add button will be displayed. */ addToBasketButton?: ReactNode; showActionBar?: boolean; onCopy?: (copied: string) => void; sequenceTools?: SequenceToolName[]; }; const Sequence = ({ sequence, accession, onShowSequence, onCopy, isCollapsible = false, isLoading = false, infoData, onBlastClick, addToBasketButton, downloadUrl, showActionBar = true, sequenceTools, }: SequenceProps) => { const [highlights, setHighlights] = useState([]); const [computeHighlights, setComputeHighlights] = useState(false); const [isCollapsed, setIsCollapsed] = useState( isCollapsible || (onShowSequence && !sequence) ); const handleShowSequenceClick = useCallback(() => { setIsCollapsed(false); // Request call of sequence if (!sequence && onShowSequence) { onShowSequence(); } }, [onShowSequence, sequence]); const sequenceStyle = useMemo( () => Object.fromEntries( highlights.map((highlight) => [ `--color-${highlight.name}`, highlight.colour, ]) ), [highlights] ); const handleToggleHighlight = useCallback((aaProp: AminoAcidProperty) => { setComputeHighlights(true); setHighlights((highlights) => { const highlightsSet = new Set(highlights); if (highlightsSet.has(aaProp)) { highlightsSet.delete(aaProp); } else { highlightsSet.add(aaProp); } return Array.from(highlightsSet); }); }, []); if (isCollapsed || !sequence) { return (
); } return (
{isCollapsible && ( )}
{showActionBar && accession && (
{/* Not sure why keys are needed, but otherwise gets the React key warnings messages and children are rendered as array... */} {downloadUrl && ( Download )} {addToBasketButton} {aaProps.map((aaProp) => { const inputId = `${accession}-${aaProp.name}`; return ( ); })}
)}
); }; export default Sequence;