← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/msa/use-msa-viewport.ts

4.48 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import {
  getMsaViewportSlice,
  MSA_ROW_HEIGHT_PX,
  type MsaViewportSlice,
} from "./virtualization";

type MsaViewportMetrics = {
  height: number;
  scrollLeft: number;
  scrollTop: number;
  width: number;
};

const EMPTY_METRICS: MsaViewportMetrics = {
  height: 0,
  scrollLeft: 0,
  scrollTop: 0,
  width: 0,
};

export function useMsaViewport({
  alignedLength,
  cellWidth,
  rowCount,
  staticRowCount,
}: {
  alignedLength: number;
  cellWidth: number;
  rowCount: number;
  staticRowCount: number;
}): {
  metrics: MsaViewportMetrics;
  scrollHorizontallyBy: (deltaX: number) => void;
  scrollToColumn: (column: number) => void;
  scrollToRow: (rowIndex: number) => void;
  setScrollContainerRef: (node: HTMLDivElement | null) => void;
  slice: MsaViewportSlice;
} {
  const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(
    null,
  );
  const [metrics, setMetrics] = useState<MsaViewportMetrics>(EMPTY_METRICS);
  const animationFrameRef = useRef<number | null>(null);
  const scrollContainerRef = useRef<HTMLDivElement | null>(null);

  const updateMetrics = useCallback((node: HTMLDivElement): void => {
    setMetrics({
      height: node.clientHeight,
      scrollLeft: node.scrollLeft,
      scrollTop: node.scrollTop,
      width: node.clientWidth,
    });
  }, []);

  const setScrollContainerRef = useCallback(
    (node: HTMLDivElement | null): void => {
      scrollContainerRef.current = node;
      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 slice = useMemo(
    () =>
      getMsaViewportSlice({
        alignedLength,
        cellWidth,
        height: metrics.height,
        rowCount,
        scrollLeft: metrics.scrollLeft,
        scrollTop: metrics.scrollTop,
        staticRowCount,
        width: metrics.width,
      }),
    [
      alignedLength,
      cellWidth,
      metrics.height,
      metrics.scrollLeft,
      metrics.scrollTop,
      metrics.width,
      rowCount,
      staticRowCount,
    ],
  );

  const scrollToColumn = useCallback(
    (column: number): void => {
      const node = scrollContainerRef.current;
      if (node == null) {
        return;
      }
      const nextScrollLeft = Math.max(0, column) * cellWidth;
      node.scrollLeft = nextScrollLeft;
      updateMetrics(node);
    },
    [cellWidth, updateMetrics],
  );

  const scrollHorizontallyBy = useCallback(
    (deltaX: number): void => {
      const node = scrollContainerRef.current;
      if (node == null || deltaX === 0) {
        return;
      }
      node.scrollLeft += deltaX;
      updateMetrics(node);
    },
    [updateMetrics],
  );

  const scrollToRow = useCallback(
    (rowIndex: number): void => {
      const node = scrollContainerRef.current;
      if (node == null) {
        return;
      }
      node.scrollTop =
        Math.max(0, rowIndex + staticRowCount) * MSA_ROW_HEIGHT_PX;
      updateMetrics(node);
    },
    [staticRowCount, updateMetrics],
  );

  return {
    metrics,
    scrollHorizontallyBy,
    scrollToColumn,
    scrollToRow,
    setScrollContainerRef,
    slice,
  };
}

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: a7562de80a6a2171670c15ef0a0b5fc1ac7ac454777d091652e43fab1465e66a