← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/sequence-overview-geometry.ts

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

↓ Download file

import { sourceRangeToDisplay, type SequenceOrientation } from "./orientation";
import type {
  SequenceFeature,
  SequenceFeatureSegment,
  SequenceRecord,
} from "./types";

export const MAX_MAP_FEATURES = 200;
export const MAX_MAP_SEGMENTS = 1_200;
export const MAX_MAP_LANES = 6;
export const MIN_MAP_FEATURE_FRACTION = 0.004;

export type MapRange = { end: number; start: number };

export type OverviewFeature = {
  feature: SequenceFeature;
  lane: number;
  segments: Array<MapRange>;
  strand: SequenceFeature["strand"];
};

export type OverviewPlan = {
  features: Array<OverviewFeature>;
  omittedForDensity: number;
  omittedForLimit: number;
  omittedSegments: number;
  remoteSegments: number;
};

/** Coordinates in the record remain source coordinates; only map geometry is oriented. */
export function createOverviewPlan(
  record: SequenceRecord,
  orientation: SequenceOrientation,
  selectedFeatureId?: string,
  circular = record.topology === "circular",
): OverviewPlan {
  const candidates = record.features.slice(0, MAX_MAP_FEATURES);
  const selected =
    selectedFeatureId == null
      ? undefined
      : record.features.find(({ id }) => id === selectedFeatureId);
  if (selected != null) {
    const index = candidates.findIndex(({ id }) => id === selected.id);
    if (index >= 0) candidates.splice(index, 1);
    else if (candidates.length === MAX_MAP_FEATURES) candidates.pop();
    candidates.unshift(selected);
  }
  const plan: OverviewPlan = {
    features: [],
    omittedForDensity: 0,
    omittedForLimit: record.features.length - candidates.length,
    omittedSegments: 0,
    remoteSegments: 0,
  };
  const lanes: Array<Array<MapRange>> = [];
  let examinedSegments = 0;
  let plannedSegments = 0;
  for (const feature of candidates) {
    const sourceSegments: Array<SequenceFeatureSegment> = feature.segments ?? [
      { start: feature.start, end: feature.end },
    ];
    const segments: Array<MapRange> = [];
    for (let index = 0; index < sourceSegments.length; index += 1) {
      if (examinedSegments >= MAX_MAP_SEGMENTS) {
        plan.omittedSegments += sourceSegments.length - index;
        break;
      }
      examinedSegments += 1;
      const segment = sourceSegments[index];
      if (segment == null) continue;
      if (segment.remoteAccession != null) {
        plan.remoteSegments += 1;
        continue;
      }
      for (const local of localMapRanges(
        segment,
        record.length,
        record.topology === "circular",
      )) {
        if (plannedSegments >= MAX_MAP_SEGMENTS) {
          plan.omittedSegments += 1;
          continue;
        }
        plannedSegments += 1;
        segments.push(sourceRangeToDisplay(local, record.length, orientation));
      }
    }
    if (segments.length === 0) continue;
    // Keep adjacent arrows and very short annotations visually separate.
    const gap = record.length * 0.008;
    const available = lanes.findIndex((lane) =>
      segments.every((segment) =>
        lane.every((occupied) => {
          const separated = (offset: number): boolean =>
            segment.end + gap < occupied.start + offset ||
            segment.start - gap > occupied.end + offset;
          return (
            separated(0) &&
            (!circular ||
              (separated(-record.length) && separated(record.length)))
          );
        }),
      ),
    );
    const lane = available >= 0 ? available : lanes.length;
    if (lane === MAX_MAP_LANES) {
      plan.omittedForDensity += 1;
      continue;
    }
    (lanes[lane] ??= []).push(...segments);
    plan.features.push({
      feature,
      lane,
      segments,
      strand:
        orientation === "forward"
          ? feature.strand
          : reverseStrand(feature.strand),
    });
  }
  return plan;
}

export function localMapRanges(
  range: MapRange,
  length: number,
  circular = false,
): Array<MapRange> {
  if (
    length < 1 ||
    !Number.isFinite(range.start) ||
    !Number.isFinite(range.end)
  )
    return [];
  if (range.start > range.end) {
    if (!circular || range.start > length || range.end < 1) return [];
    return [
      { start: Math.max(1, range.start), end: length },
      { start: 1, end: Math.min(length, range.end) },
    ];
  }
  if (range.end < 1 || range.start > length) return [];
  return [
    { start: Math.max(1, range.start), end: Math.min(length, range.end) },
  ];
}

export function coordinateFromFraction(
  fraction: number,
  length: number,
): number {
  if (length <= 1 || !Number.isFinite(length)) return 1;
  const clamped = Number.isFinite(fraction)
    ? Math.max(0, Math.min(1, fraction))
    : 0;
  return Math.max(1, Math.min(length, Math.floor(clamped * length) + 1));
}

export function coordinateFromCircularPoint(
  x: number,
  y: number,
  length: number,
): number {
  const angle = Math.atan2(y, x) + Math.PI / 2;
  return coordinateFromFraction(
    ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2),
    length,
  );
}

export function linearFeaturePath(
  x: number,
  y: number,
  width: number,
  height: number,
  strand: SequenceFeature["strand"],
): string {
  const head = Math.min(8, width / 2);
  if (strand === "+") {
    return `M ${x} ${y} H ${x + width - head} L ${x + width} ${y + height / 2} L ${x + width - head} ${y + height} H ${x} Z`;
  }
  if (strand === "-") {
    return `M ${x + head} ${y} H ${x + width} V ${y + height} H ${x + head} L ${x} ${y + height / 2} Z`;
  }
  return `M ${x} ${y} H ${x + width} V ${y + height} H ${x} Z`;
}

export function circularFeaturePath(
  start: number,
  end: number,
  length: number,
  centerX: number,
  centerY: number,
  radius: number,
  minimumFraction = 0,
): string {
  const { from, to } = circularAngles(start, end, length, minimumFraction);
  const first = polar(centerX, centerY, radius, from);
  if (to - from >= Math.PI * 2) {
    const opposite = polar(centerX, centerY, radius, from + Math.PI);
    return `M ${first} A ${radius} ${radius} 0 1 1 ${opposite} A ${radius} ${radius} 0 1 1 ${first}`;
  }
  return `M ${first} A ${radius} ${radius} 0 ${to - from > Math.PI ? 1 : 0} 1 ${polar(centerX, centerY, radius, to)}`;
}

/** A filled annular arrow; unknown strands are deliberately not assigned a direction. */
export function circularArrowPath(
  range: MapRange,
  length: number,
  center: number,
  radius: number,
  strand: SequenceFeature["strand"],
): string {
  const { from, to } = circularAngles(
    range.start,
    range.end,
    length,
    MIN_MAP_FEATURE_FRACTION,
  );
  const inner = radius - 4;
  const outer = radius + 4;
  const head = Math.min(0.11, (to - from) / 2);
  if (strand === "+") {
    const shoulder = to - head;
    const large = shoulder - from > Math.PI ? 1 : 0;
    return `M ${polar(center, center, inner, from)} A ${inner} ${inner} 0 ${large} 1 ${polar(center, center, inner, shoulder)} L ${polar(center, center, radius, to)} L ${polar(center, center, outer, shoulder)} A ${outer} ${outer} 0 ${large} 0 ${polar(center, center, outer, from)} Z`;
  }
  if (strand === "-") {
    const shoulder = from + head;
    const large = to - shoulder > Math.PI ? 1 : 0;
    return `M ${polar(center, center, inner, to)} A ${inner} ${inner} 0 ${large} 0 ${polar(center, center, inner, shoulder)} L ${polar(center, center, radius, from)} L ${polar(center, center, outer, shoulder)} A ${outer} ${outer} 0 ${large} 1 ${polar(center, center, outer, to)} Z`;
  }
  // Two arcs per edge also represent a feature covering the entire molecule.
  const middle = (from + to) / 2;
  return `M ${polar(center, center, inner, from)} A ${inner} ${inner} 0 0 1 ${polar(center, center, inner, middle)} A ${inner} ${inner} 0 0 1 ${polar(center, center, inner, to)} L ${polar(center, center, outer, to)} A ${outer} ${outer} 0 0 0 ${polar(center, center, outer, middle)} A ${outer} ${outer} 0 0 0 ${polar(center, center, outer, from)} Z`;
}

function circularAngles(
  start: number,
  end: number,
  length: number,
  minimumFraction = 0,
): { from: number; to: number } {
  const safeLength = Math.max(1, length);
  const from = ((start - 1) / safeLength) * Math.PI * 2 - Math.PI / 2;
  const span =
    Math.min(1, Math.max(1, end - start + 1) / safeLength) * Math.PI * 2;
  const visibleSpan = Math.max(span, minimumFraction * Math.PI * 2);
  return {
    from: from + (span - visibleSpan) / 2,
    to: from + (span + visibleSpan) / 2,
  };
}

function polar(
  centerX: number,
  centerY: number,
  radius: number,
  angle: number,
): string {
  return `${(centerX + radius * Math.cos(angle)).toFixed(3)} ${(centerY + radius * Math.sin(angle)).toFixed(3)}`;
}

function reverseStrand(
  strand: SequenceFeature["strand"],
): SequenceFeature["strand"] {
  return strand === "+" ? "-" : strand === "-" ? "+" : strand;
}

SHA-256: a09bfb784a049ff0a424175c98e2d1a944322e578879b386642f5f89532e0b55