← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/workbench-exports.ts
44.3 KB · Sep 30, 2026 · 23:01 UTC
import { buildAlignedFasta, buildVisibleRangeSvg } from "./msa/exports";
import type { MsaColumnRange, MsaDocument, MsaSequenceRow } from "./msa/types";
import {
buildFastaExport,
buildFastqExport,
buildSelectedSequenceExport,
} from "./sequence/exports";
import {
extractSelectedSequence,
getSelectionSegments,
selectionContainsCoordinate,
selectionOverlapsFeature,
} from "./sequence/selection";
import type {
SequenceTrack,
TrackFeature,
TrackRead,
TrackVariant,
} from "./sequence/tracks";
import type {
SequenceDocument,
SequenceFeature,
SequenceRecord,
SequenceSelection,
} from "./sequence/types";
import { neutralizeSpreadsheetFormula } from "./spreadsheet-safety";
export type WorkbenchExport = {
content: string;
format: string;
mediaType: string;
name: string;
};
export function exportSequenceWorkbench({
document,
format,
name,
recordId,
scope,
selection,
tracks = [],
}: {
document: SequenceDocument;
format:
| "bed"
| "csv"
| "embl"
| "fasta"
| "fastq"
| "genbank"
| "gff3"
| "gtf"
| "json"
| "pdf"
| "svg"
| "tsv"
| "vcf";
name?: string;
recordId: string;
scope: "all" | "selection" | "visible";
selection?: SequenceSelection;
tracks?: Array<SequenceTrack>;
}): WorkbenchExport {
const record =
document.records.find(({ id }) => id === recordId) ?? document.records[0];
if (record == null)
throw new Error("No sequence record is available to export.");
if (
scope === "selection" &&
(selection == null || selection.recordId !== record.id)
) {
throw new Error("A selected export requires a selection on the active record.");
}
const baseName = sanitizeFileName(name ?? record.sourceLabel) || "sequence";
const scopedRecord =
scope === "selection" && selection != null
? projectSelectedSequenceRecord(record, selection)
: record;
const scopedRecords = scope === "all" ? document.records : [scopedRecord];
if (format === "fasta") {
return {
content:
scope === "selection" && selection != null
? buildSelectedSequenceExport({ document, selection })
: buildFastaExport(scopedRecords),
format,
mediaType: "text/x-fasta",
name: `${baseName}.fasta`,
};
}
if (format === "fastq") {
return {
content: scopedRecords.map(buildFastqExport).join("\n"),
format,
mediaType: "text/x-fastq",
name: `${baseName}.fastq`,
};
}
if (format === "svg") {
return {
content: buildSequenceFigureSvg(
scopedRecord,
scope === "selection"
? { end: scopedRecord.length, recordId: record.id, start: 1 }
: selection,
),
format,
mediaType: "image/svg+xml",
name: `${baseName}.svg`,
};
}
if (format === "genbank") {
return {
content: scopedRecords.map(buildGenBank).join(""),
format,
mediaType: "text/x-genbank",
name: `${baseName}.gb`,
};
}
if (format === "embl") {
return {
content: scopedRecords.map(buildEmbl).join(""),
format,
mediaType: "text/x-embl",
name: `${baseName}.embl`,
};
}
const features =
scope === "selection"
? scopedRecord.features
: featuresInScope(record, selection, scope);
if (format === "gff3") {
return {
content: buildGff3(scopedRecord, features),
format,
mediaType: "text/x-gff3",
name: `${baseName}.gff3`,
};
}
if (format === "gtf") {
return {
content: buildGtf(scopedRecord, features),
format,
mediaType: "text/x-gtf",
name: `${baseName}.gtf`,
};
}
if (format === "pdf") {
return {
content: buildScientificPdf(`${scopedRecord.sourceLabel} sequence report`, [
`Molecule: ${scopedRecord.molecule}`,
`Length: ${scopedRecord.length.toLocaleString()} residues`,
`Scope: ${scope}`,
...(selection == null
? []
: [
`Selection: ${selection.start}-${selection.end} (1-based inclusive)`,
]),
`Annotations: ${features.length.toLocaleString()}`,
`Sequence: ${scopedRecord.sequence.slice(0, 120)}`,
]),
format,
mediaType: "application/pdf",
name: `${baseName}.pdf`,
};
}
if (format === "bed") {
return {
content: buildBed(scopedRecord, features),
format,
mediaType: "text/x-bed",
name: `${baseName}.bed`,
};
}
if (format === "csv" || format === "tsv") {
const delimiter = format === "csv" ? "," : "\t";
return {
content: buildFeatureTable(scopedRecord, features, delimiter),
format,
mediaType: format === "csv" ? "text/csv" : "text/tab-separated-values",
name: `${baseName}.${format}`,
};
}
if (format === "vcf") {
return {
content: buildVcf({ record, scope, selection, tracks }),
format,
mediaType: "text/x-vcf",
name: `${baseName}.vcf`,
};
}
const scopedJson = projectSequenceJsonExport({
document,
record,
scope,
scopedRecord,
selection,
tracks,
});
return {
content: JSON.stringify(
{
document: scopedJson.document,
exportedAt: new Date().toISOString(),
scope,
selection: selection ?? null,
tracks: scopedJson.tracks,
},
null,
2,
),
format,
mediaType: "application/json",
name: `${baseName}.json`,
};
}
function projectSelectedSequenceRecord(
record: SequenceRecord,
selection: SequenceSelection,
): SequenceRecord {
let offset = 0;
const windows = getSelectionSegments(selection).map((segment) => {
const projected = { ...segment, offset };
offset += segment.end - segment.start + 1;
return projected;
});
const features = record.features.flatMap((feature) => {
const sourceSegments = feature.segments ?? [
{ end: feature.end, start: feature.start },
];
const segments = sourceSegments.flatMap((segment) =>
windows.flatMap((window) => {
const start = Math.max(segment.start, window.start);
const end = Math.min(segment.end, window.end);
return start > end
? []
: [
{
...segment,
end: window.offset + end - window.start + 1,
start: window.offset + start - window.start + 1,
},
];
}),
);
if (segments.length === 0) return [];
const {
sourceLocation: _sourceLocation,
translation: _translation,
translationCoordinateMap: _translationCoordinateMap,
translationMappingUnavailableReason: _translationMappingUnavailableReason,
translationSource: _translationSource,
translationTrackReliable: _translationTrackReliable,
...safeFeature
} = feature;
return [
{
...safeFeature,
end: Math.max(...segments.map((segment) => segment.end)),
qualifiers: Object.fromEntries(
Object.entries(feature.qualifiers).filter(
([key]) => key.toLowerCase() !== "translation",
),
),
segments,
start: Math.min(...segments.map((segment) => segment.start)),
},
];
});
const sequence = extractSelectedSequence(record, selection);
const { chromatogram: _chromatogram, ...sequenceRecord } = record;
return {
...sequenceRecord,
features,
length: sequence.length,
...(record.quality == null
? {}
: {
quality: {
ascii: windows
.map(({ end, start }) =>
record.quality?.ascii.slice(start - 1, end),
)
.join(""),
phred: windows.flatMap(({ end, start }) =>
record.quality?.phred.slice(start - 1, end) ?? [],
),
},
}),
sequence,
topology: "linear",
};
}
function projectSequenceJsonExport({
document,
record,
scope,
scopedRecord,
selection,
tracks,
}: {
document: SequenceDocument;
record: SequenceRecord;
scope: "all" | "selection" | "visible";
scopedRecord: SequenceRecord;
selection?: SequenceSelection;
tracks: Array<SequenceTrack>;
}): { document: SequenceDocument; tracks: Array<SequenceTrack> } {
if (scope === "all") return { document, tracks };
const {
fastqSummary: _fastqSummary,
recordInventory: inventory,
...documentWithoutAggregates
} = document;
return {
document: {
...documentWithoutAggregates,
...(inventory == null
? {}
: {
recordInventory: {
materializedCount: 1,
totalCount: 1,
truncated: false,
},
}),
records: [scopedRecord],
warnings:
scope === "selection" && record.chromatogram != null
? [
{
code: "source-trace-not-carried",
message:
"The source chromatogram and its base-call confidence values were not carried into this subsequence export because its sample coordinates were not remapped. The original source read is unchanged.",
severity: "warning",
},
]
: [],
},
tracks: tracks.flatMap((track) =>
projectSequenceTrack({ record, scope, selection, track }),
),
};
}
function projectSequenceTrack({
record,
scope,
selection,
track,
}: {
record: SequenceRecord;
scope: "selection" | "visible";
selection?: SequenceSelection;
track: SequenceTrack;
}): Array<SequenceTrack> {
const matchesReference = (reference: string): boolean =>
normalizeVariantReference(reference) ===
normalizeVariantReference(record.sourceLabel) ||
normalizeVariantReference(reference) ===
normalizeVariantReference(record.id);
const inSelection = (coordinate: number): boolean =>
scope !== "selection" ||
(selection != null && selectionContainsCoordinate(selection, coordinate));
const variants = (track.variants ?? []).filter(
(variant) =>
matchesReference(variant.reference) && inSelection(variant.position),
);
const features = (track.features ?? []).flatMap((feature) => {
if (!matchesReference(feature.reference)) return [];
if (scope !== "selection" || selection == null) return [feature];
return clipTrackFeatureToSelection(feature, selection);
});
const reads = (track.reads ?? []).flatMap((read): Array<TrackRead> => {
if (!matchesReference(read.reference)) return [];
if (scope !== "selection" || selection == null) return [read];
if (
!getSelectionSegments(selection).some(
({ end, start }) => read.position >= start && read.end <= end,
)
) {
return [];
}
if (
read.mateReference == null ||
(matchesReference(read.mateReference) &&
read.matePosition != null &&
inSelection(read.matePosition))
) {
return [read];
}
const { matePosition: _matePosition, mateReference: _mateReference, ...safe } =
read;
return [safe];
});
const items: Array<TrackFeature | TrackRead | TrackVariant> =
track.kind === "variants"
? variants
: track.kind === "annotations"
? features
: reads;
if (items.length === 0) return [];
const {
features: _features,
reads: _reads,
variants: _variants,
vcfHeader: header,
...safeTrack
} = track;
const references = [...new Set(items.map((item) => item.reference))];
return [
{
...safeTrack,
...(track.kind === "annotations" ? { features } : {}),
...(track.kind === "reads" ? { reads } : {}),
...(track.kind === "variants" ? { variants } : {}),
...(header == null || track.kind !== "variants"
? {}
: {
vcfHeader: {
...header,
metaLines: mergeVcfMetadata({
scope,
tracks: [track],
variants: variants.map((variant) => ({ track, variant })),
}),
},
}),
mapping: {
matchedReference: references[0] ?? record.sourceLabel,
requestedReference: record.sourceLabel,
status: "matched",
unmatchedReferences: [],
},
summary: {
...track.summary,
itemCount: items.length,
materializedItemCount: items.length,
references,
},
},
];
}
function clipTrackFeatureToSelection(
feature: TrackFeature,
selection: SequenceSelection,
): Array<TrackFeature> {
const sourceSegments = feature.segments ?? [
{ end: feature.end, start: feature.start },
];
return getSelectionSegments(selection).flatMap((window) => {
const intersect = (segment: { end: number; start: number }) => {
const start = Math.max(segment.start, window.start);
const end = Math.min(segment.end, window.end);
return start <= end ? [{ end, start }] : [];
};
const segments = sourceSegments.flatMap(intersect);
if (segments.length === 0) return [];
return [
{
...feature,
...(feature.codingSegments == null
? {}
: { codingSegments: feature.codingSegments.flatMap(intersect) }),
end: Math.max(...segments.map(({ end }) => end)),
segments,
start: Math.min(...segments.map(({ start }) => start)),
},
];
});
}
function buildSequenceFigureSvg(
record: SequenceRecord,
selection: SequenceSelection | undefined,
): string {
const width = 1_200;
const featureRows = record.features.slice(0, 2_000);
const height = Math.max(180, 130 + Math.ceil(featureRows.length / 8) * 18);
const scale = (coordinate: number): number =>
40 +
((Math.max(1, coordinate) - 1) / Math.max(1, record.length)) * (width - 80);
const featureShapes = featureRows.flatMap((feature, featureIndex) =>
(feature.segments ?? [feature]).map((segment) => {
const x = scale(segment.start);
const segmentWidth = Math.max(2, scale(segment.end + 1) - x);
const y =
92 + Math.floor(featureIndex / 8) * 18 + (featureIndex % 8) * 1.5;
return `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${segmentWidth.toFixed(2)}" height="10" rx="2" fill="${svgFeatureColor(feature.type)}"><title>${escapeXml(feature.label ?? feature.type)} ${segment.start}-${segment.end}</title></rect>`;
}),
);
const selectionShapes =
selection == null
? []
: (selection.segments ?? [selection]).map((segment) => {
const x = scale(segment.start);
return `<rect x="${x.toFixed(2)}" y="54" width="${Math.max(2, scale(segment.end + 1) - x).toFixed(2)}" height="28" fill="#10b981" fill-opacity="0.24" stroke="#059669" stroke-width="2"/>`;
});
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">`,
`<title id="title">${escapeXml(record.sourceLabel)} sequence map</title>`,
`<desc id="description">Linear one-based inclusive map of ${record.length} residues with ${record.features.length} features.</desc>`,
`<rect width="100%" height="100%" fill="white"/>`,
`<text x="40" y="28" font-family="system-ui,sans-serif" font-size="17" font-weight="600" fill="#111827">${escapeXml(record.sourceLabel)}</text>`,
`<text x="40" y="46" font-family="system-ui,sans-serif" font-size="11" fill="#4b5563">${record.length.toLocaleString()} residues · ${escapeXml(record.molecule)} · 1-based inclusive</text>`,
`<line x1="40" x2="${width - 40}" y1="68" y2="68" stroke="#64748b" stroke-width="5" stroke-linecap="round"/>`,
...selectionShapes,
...featureShapes,
"</svg>",
].join("\n");
}
export function exportAlignmentWorkbench({
document,
format,
name,
newick,
scope,
selectedColumns,
selectedRows = [],
visibleRows,
}: {
document: MsaDocument;
format:
| "a3m"
| "aligned-fasta"
| "clustal"
| "json"
| "newick"
| "pdf"
| "stockholm"
| "svg"
| "tsv";
name?: string;
newick?: string | null;
scope: "all" | "selection" | "visible";
selectedColumns?: MsaColumnRange | null;
selectedRows?: Array<string>;
visibleRows: Array<MsaSequenceRow>;
}): WorkbenchExport {
const baseName =
sanitizeFileName(name ?? document.format ?? "alignment") || "alignment";
const selectedRowIds = new Set(selectedRows);
const rows =
scope === "all"
? document.rows
: scope === "selection" && selectedRowIds.size > 0
? document.rows.filter(({ id }) => selectedRowIds.has(id))
: visibleRows;
const startColumn =
scope === "selection" && selectedColumns != null
? selectedColumns.start
: 0;
const endColumn =
scope === "selection" && selectedColumns != null
? selectedColumns.end
: document.alignedLength;
const scopedRows = rows.map((row) => ({
...row,
alignedSequence: row.alignedSequence.slice(startColumn, endColumn),
...(scope === "all"
? {}
: {
ungappedLength: row.alignedSequence
.slice(startColumn, endColumn)
.replaceAll(/[.\-]/gu, "").length,
}),
}));
if (format === "aligned-fasta") {
return {
content: `${buildAlignedFasta(scopedRows)}\n`,
format,
mediaType: "text/x-fasta",
name: `${baseName}.aln-fasta`,
};
}
if (format === "a3m") {
return {
content: buildA3m(
scopedRows,
document.insertions,
startColumn,
endColumn,
),
format,
mediaType: "text/x-a3m",
name: `${baseName}.a3m`,
};
}
if (format === "clustal") {
return {
content: buildClustal(scopedRows),
format,
mediaType: "text/x-clustal",
name: `${baseName}.aln`,
};
}
if (format === "stockholm") {
return {
content: buildStockholm({
document,
endColumn,
rows: scopedRows,
startColumn,
}),
format,
mediaType: "text/x-stockholm",
name: `${baseName}.sto`,
};
}
if (format === "pdf") {
return {
content: buildScientificPdf("Multiple sequence alignment report", [
`Rows: ${scopedRows.length.toLocaleString()}`,
`Columns: ${Math.max(0, endColumn - startColumn).toLocaleString()}`,
`Scope: ${scope}`,
...scopedRows
.slice(0, 32)
.map((row) => `${row.label}: ${row.alignedSequence.slice(0, 96)}`),
]),
format,
mediaType: "application/pdf",
name: `${baseName}.pdf`,
};
}
if (format === "tsv") {
return {
content: [
"row_id\trow_label\taligned_sequence",
...scopedRows.map(
(row) =>
`${escapeTsv(row.id)}\t${escapeTsv(row.label)}\t${escapeTsv(row.alignedSequence)}`,
),
].join("\n"),
format,
mediaType: "text/tab-separated-values",
name: `${baseName}.tsv`,
};
}
if (format === "svg") {
return {
content: buildVisibleRangeSvg({
endColumn,
rows,
startColumn,
}),
format,
mediaType: "image/svg+xml",
name: `${baseName}.svg`,
};
}
if (format === "newick") {
if (scope !== "all") {
throw new Error(
"Newick export requires all scope because guide trees include every alignment row.",
);
}
if (newick == null)
throw new Error("Compute a guide tree before exporting Newick.");
return {
content: `${newick.trim().replace(/;?$/u, ";")}\n`,
format,
mediaType: "text/x-newick",
name: `${baseName}.nwk`,
};
}
return {
content: JSON.stringify(
{
document:
scope === "all"
? document
: projectAlignmentJsonExport({
document,
endColumn,
rows: scopedRows,
startColumn,
}),
exportedAt: new Date().toISOString(),
scope,
},
null,
2,
),
format,
mediaType: "application/json",
name: `${baseName}.json`,
};
}
function projectAlignmentJsonExport({
document,
endColumn,
rows,
startColumn,
}: {
document: MsaDocument;
endColumn: number;
rows: Array<MsaSequenceRow>;
startColumn: number;
}): MsaDocument {
const rowIds = new Set(rows.map(({ id }) => id));
const annotations = document.annotations
.filter(
(annotation) =>
annotation.metadata?.source !== "GR" ||
(typeof annotation.metadata.rowId === "string" &&
rowIds.has(annotation.metadata.rowId)),
)
.map((annotation) => ({
...annotation,
values: annotation.values.slice(startColumn, endColumn),
}));
const insertions = document.insertions
.filter(
(insertion) =>
rowIds.has(insertion.rowId) &&
insertion.afterAlignmentColumn >= startColumn - 1 &&
insertion.afterAlignmentColumn < endColumn,
)
.map((insertion) => ({
...insertion,
afterAlignmentColumn: insertion.afterAlignmentColumn - startColumn,
...(insertion.sourceRowIndex == null
? {}
: {
sourceRowIndex: rows.findIndex(({ id }) => id === insertion.rowId),
}),
}));
const rnaStructure =
document.rnaStructure == null
? null
: {
...document.rnaStructure,
motifTracks: document.rnaStructure.motifTracks
.filter(
(track) =>
track.metadata?.source !== "GR" ||
(typeof track.metadata.rowId === "string" &&
rowIds.has(track.metadata.rowId)),
)
.map((track) => ({
...track,
values: track.values.slice(startColumn, endColumn),
})),
pairs: document.rnaStructure.pairs
.filter(
({ leftColumn, rightColumn }) =>
leftColumn >= startColumn && rightColumn < endColumn,
)
.map((pair) => ({
...pair,
leftColumn: pair.leftColumn - startColumn,
rightColumn: pair.rightColumn - startColumn,
})),
rawStructure: document.rnaStructure.rawStructure.slice(
startColumn,
endColumn,
),
...(document.rnaStructure.referenceTrack == null
? {}
: {
referenceTrack: document.rnaStructure.referenceTrack.slice(
startColumn,
endColumn,
),
}),
warnings: [],
};
const residueCount = rows.reduce(
(count, row) => count + row.alignedSequence.length,
0,
);
const gapCount = rows.reduce(
(count, row) =>
count + (row.alignedSequence.match(/[.\-]/gu)?.length ?? 0),
0,
);
return {
...document,
alignedLength: endColumn - startColumn,
annotations,
insertions,
rawSummary: {
...document.rawSummary,
gapFraction: residueCount === 0 ? 0 : gapCount / residueCount,
maxLabelLength: Math.max(0, ...rows.map(({ label }) => label.length)),
sequenceCount: rows.length,
structureTrackCount: annotations.filter(({ kind }) =>
kind.includes("structure"),
).length,
visibleSequenceCount: rows.filter(({ hidden }) => !hidden).length,
},
rnaStructure,
rows,
warnings: [],
};
}
function buildA3m(
rows: Array<MsaSequenceRow>,
insertions: MsaDocument["insertions"],
startColumn: number,
endColumn: number,
): string {
const rowInsertions = new Map<string, Map<number, Array<string>>>();
for (const insertion of insertions) {
if (
insertion.afterAlignmentColumn < startColumn - 1 ||
insertion.afterAlignmentColumn >= endColumn
) {
continue;
}
const columns =
rowInsertions.get(insertion.rowId) ?? new Map<number, Array<string>>();
const residues = columns.get(insertion.afterAlignmentColumn) ?? [];
residues.push(insertion.residues.toLowerCase());
columns.set(insertion.afterAlignmentColumn, residues);
rowInsertions.set(insertion.rowId, columns);
}
return `${rows
.map((row) => {
const columns = rowInsertions.get(row.id);
let sequence = (columns?.get(startColumn - 1) ?? []).join("");
for (let column = 0; column < row.alignedSequence.length; column += 1) {
sequence += row.alignedSequence[column]?.toUpperCase() ?? "";
sequence += (columns?.get(startColumn + column) ?? []).join("");
}
const label = safeAlignmentLabel(row.label, row.id);
const description =
row.description == null
? ""
: ` ${row.description.replace(/[\r\n]/gu, " ")}`;
return `>${label}${description}\n${sequence.replace(/\./gu, "-")}`;
})
.join("\n")}\n`;
}
function buildClustal(rows: Array<MsaSequenceRow>): string {
const labels = rows.map((row) => safeAlignmentLabel(row.label, row.id));
const labelWidth = Math.max(12, ...labels.map((label) => label.length));
const alignedLength = rows[0]?.alignedSequence.length ?? 0;
const blocks = ["CLUSTAL W multiple sequence alignment", ""];
for (let start = 0; start < alignedLength; start += 60) {
for (const [index, row] of rows.entries()) {
blocks.push(
`${(labels[index] ?? row.id).padEnd(labelWidth)} ${row.alignedSequence.slice(start, start + 60)}`,
);
}
blocks.push("");
}
return `${blocks.join("\n")}\n`;
}
function buildStockholm({
document,
endColumn,
rows,
startColumn,
}: {
document: MsaDocument;
endColumn: number;
rows: Array<MsaSequenceRow>;
startColumn: number;
}): string {
const labels = rows.map((row) => safeAlignmentLabel(row.label, row.id));
const labelWidth = Math.max(1, ...labels.map((label) => label.length));
const familyMetadata = Object.entries(document.formatMetadata ?? {}).flatMap(
([key, value]) => {
if (!key.startsWith("GF:")) return [];
const tag = safeStockholmTag(key.slice(3));
return splitStockholmMetadata(value).map(
(entry) => `#=GF ${tag} ${entry}`,
);
},
);
const rowLines = rows.flatMap((row, index) => {
const label = labels[index] ?? row.id;
const rowMetadata = Object.entries(row.metadata ?? {}).flatMap(
([key, value]) => {
if (!key.startsWith("GS:")) return [];
const tag = safeStockholmTag(key.slice(3));
return splitStockholmMetadata(value).map(
(entry) => `#=GS ${label} ${tag} ${entry}`,
);
},
);
const rowAnnotations = document.annotations.flatMap((annotation) => {
if (
annotation.metadata?.source !== "GR" ||
annotation.metadata.rowId !== row.id
) {
return [];
}
const tag = safeStockholmTag(
String(annotation.metadata.tag ?? annotation.label),
);
return [
`#=GR ${label} ${tag} ${sliceStockholmAnnotation(
annotation.values,
startColumn,
endColumn,
annotation.label,
)}`,
];
});
return [
...rowMetadata,
`${label.padEnd(labelWidth)} ${row.alignedSequence}`,
...rowAnnotations,
];
});
const columnAnnotations = document.annotations.flatMap((annotation) => {
if (annotation.metadata?.source !== "GC") return [];
const tag = safeStockholmTag(
String(annotation.metadata.tag ?? annotation.label),
);
return [
`#=GC ${tag} ${sliceStockholmAnnotation(
annotation.values,
startColumn,
endColumn,
annotation.label,
)}`,
];
});
return [
"# STOCKHOLM 1.0",
...familyMetadata,
...rowLines,
...columnAnnotations,
"//",
"",
].join("\n");
}
function splitStockholmMetadata(value: string): Array<string> {
return value
.split(" | ")
.map((entry) => entry.replaceAll(/[\r\n]+/gu, " ").trim())
.filter((entry) => entry.length > 0);
}
function safeStockholmTag(value: string): string {
return value.replaceAll(/[\s\r\n]+/gu, "_") || "annotation";
}
function sliceStockholmAnnotation(
value: string,
startColumn: number,
endColumn: number,
label: string,
): string {
if (value.length < endColumn) {
throw new Error(
`Stockholm annotation ${label} does not cover the exported alignment columns.`,
);
}
return value.slice(startColumn, endColumn);
}
function safeAlignmentLabel(label: string, fallback: string): string {
return (
label.replace(/[\s\r\n]+/gu, "_") || fallback.replace(/[\s\r\n]+/gu, "_")
);
}
function buildScientificPdf(title: string, lines: Array<string>): string {
const safe = (value: string) =>
value.replace(/[^\x20-\x7e]/gu, "?").replace(/[\\()]/gu, "\\$&");
const visibleLines = [title, ...lines].slice(0, 45);
const stream = [
"BT",
"/F1 12 Tf",
"50 790 Td",
"15 TL",
...visibleLines.map(
(line, index) =>
`${index === 0 ? "" : "T* "}(${safe(line.slice(0, 140))}) Tj`,
),
"ET",
].join("\n");
const encoder = new TextEncoder();
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
`<< /Length ${encoder.encode(stream).byteLength} >>\nstream\n${stream}\nendstream`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let pdf = "%PDF-1.4\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(encoder.encode(pdf).byteLength);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xrefOffset = encoder.encode(pdf).byteLength;
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
return `${pdf}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
}
function buildGenBank(record: SequenceRecord): string {
const locus = record.sourceLabel.slice(0, 16) || "SEQUENCE";
const topology = record.topology === "circular" ? "circular" : "linear";
const molecule =
record.molecule === "protein"
? "PROTEIN"
: record.molecule === "rna"
? "RNA"
: "DNA";
const unit = record.molecule === "protein" ? "aa" : "bp";
const featureLines = record.features.flatMap((feature) => [
` ${feature.type.padEnd(15)} ${formatFeatureLocation(feature)}`,
...Object.entries(feature.qualifiers).flatMap(([key, value]) =>
(Array.isArray(value) ? value : [value]).map(
(item) => ` /${key}="${escapeQualifier(item)}"`,
),
),
]);
const origin = wrap(record.sequence.toLowerCase(), 60).map((line, index) => {
const groups = line.match(/.{1,10}/gu)?.join(" ") ?? line;
return `${String(index * 60 + 1).padStart(9)} ${groups}`;
});
return [
`LOCUS ${locus.padEnd(16)} ${String(record.length).padStart(11)} ${unit} ${molecule.padEnd(7)} ${topology}`,
`DEFINITION ${record.description ?? record.sourceLabel}.`,
`ACCESSION ${record.sourceLabel}`,
"FEATURES Location/Qualifiers",
...featureLines,
"ORIGIN",
...origin,
"//",
"",
].join("\n");
}
function buildEmbl(record: SequenceRecord): string {
const molecule =
record.molecule === "protein"
? "PROTEIN"
: record.molecule === "rna"
? "RNA"
: "DNA";
const unit = record.molecule === "protein" ? "AA" : "BP";
const featureLines = record.features.flatMap((feature) => [
`FT ${feature.type.padEnd(15)} ${formatFeatureLocation(feature)}`,
...Object.entries(feature.qualifiers).flatMap(([key, value]) =>
(Array.isArray(value) ? value : [value]).map(
(item) => `FT /${key}="${escapeQualifier(item)}"`,
),
),
]);
const sequenceLines = wrap(record.sequence.toLowerCase(), 60).map(
(line, index) =>
` ${line.match(/.{1,10}/gu)?.join(" ") ?? line} ${Math.min(record.length, (index + 1) * 60)}`,
);
return [
`ID ${record.sourceLabel}; SV 1; ${record.topology === "circular" ? "circular" : "linear"}; ${molecule}; UNC; ${record.length} ${unit}.`,
`AC ${record.sourceLabel};`,
`DE ${record.description ?? record.sourceLabel}`,
"FH Key Location/Qualifiers",
"FH",
...featureLines,
`SQ Sequence ${record.length} ${unit};`,
...sequenceLines,
"//",
"",
].join("\n");
}
function buildGff3(
record: SequenceRecord,
features: Array<SequenceFeature>,
): string {
const rows = features.flatMap((feature) =>
(feature.segments ?? [{ end: feature.end, start: feature.start }]).map(
(segment, index) =>
[
record.sourceLabel,
"sequence-viewer",
feature.type,
segment.start,
segment.end,
".",
feature.strand,
".",
[
`ID=${encodeURIComponent(feature.id)}${index === 0 ? "" : `.${index + 1}`}`,
feature.label == null
? null
: `Name=${encodeURIComponent(feature.label)}`,
...Object.entries(feature.qualifiers).map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
(Array.isArray(value) ? value : [value]).join(","),
)}`,
),
]
.filter(Boolean)
.join(";"),
].join("\t"),
),
);
return ["##gff-version 3", ...rows, ""].join("\n");
}
function buildGtf(
record: SequenceRecord,
features: Array<SequenceFeature>,
): string {
const escapeGtf = (value: string): string =>
value
.replace(/\\/gu, "\\\\")
.replace(/"/gu, '\\"')
.replace(/[\r\n]/gu, " ");
const firstQualifier = (
feature: SequenceFeature,
key: string,
): string | null => {
const value = feature.qualifiers[key];
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
};
const lines = features.flatMap((feature) => {
const segments = feature.segments ?? [
{ end: feature.end, start: feature.start },
];
const segmentPhases = getGtfSegmentPhases(feature, segments);
return segments.map((segment, segmentIndex) => {
const geneId =
firstQualifier(feature, "gene_id") ??
firstQualifier(feature, "gene") ??
firstQualifier(feature, "locus_tag") ??
feature.id;
const transcriptId =
firstQualifier(feature, "transcript_id") ?? feature.id;
const attributes = [
`gene_id "${escapeGtf(geneId)}"`,
`transcript_id "${escapeGtf(transcriptId)}"`,
...(feature.label == null
? []
: [`gene_name "${escapeGtf(feature.label)}"`]),
].join("; ");
return [
record.sourceLabel,
"sequence-viewer",
feature.type,
segment.start,
segment.end,
".",
feature.strand,
segmentPhases[segmentIndex] ?? ".",
`${attributes};`,
].join("\t");
});
});
return `${lines.join("\n")}\n`;
}
function getGtfSegmentPhases(
feature: SequenceFeature,
segments: ReadonlyArray<{ end: number; start: number }>,
): Array<string> {
if (feature.type.toLowerCase() !== "cds") {
return segments.map(() => ".");
}
const qualifierCodonStart = feature.qualifiers.codon_start;
const qualifierPhase = feature.qualifiers.gtf_phase;
const rawCodonStart =
feature.codonStart ??
Number(
Array.isArray(qualifierCodonStart)
? qualifierCodonStart[0]
: qualifierCodonStart,
);
const rawPhase = Number(
Array.isArray(qualifierPhase) ? qualifierPhase[0] : qualifierPhase,
);
let phase =
rawCodonStart === 2 || rawCodonStart === 3
? rawCodonStart - 1
: rawPhase === 1 || rawPhase === 2
? rawPhase
: 0;
const phases = segments.map(() => "0");
// Feature locations already store segments in biological transcript order,
// including reverse-strand complement(join(...)) locations.
for (const [index, segment] of segments.entries()) {
phases[index] = String(phase);
const translatedBases = segment.end - segment.start + 1 - phase;
phase = (3 - (translatedBases % 3)) % 3;
}
return phases;
}
function buildBed(
record: SequenceRecord,
features: Array<SequenceFeature>,
): string {
return `${features
.flatMap((feature) =>
(feature.segments ?? [{ end: feature.end, start: feature.start }]).map(
(segment) =>
[
record.sourceLabel,
segment.start - 1,
segment.end,
feature.label ?? feature.id,
0,
feature.strand,
].join("\t"),
),
)
.join("\n")}\n`;
}
function buildFeatureTable(
record: SequenceRecord,
features: Array<SequenceFeature>,
delimiter: string,
): string {
const rows = features.map((feature) =>
[
record.sourceLabel,
feature.id,
feature.type,
feature.label ?? "",
feature.start,
feature.end,
feature.strand,
JSON.stringify(feature.qualifiers),
]
.map((value) => quoteDelimited(String(value), delimiter))
.join(delimiter),
);
return [
[
"record",
"feature_id",
"type",
"label",
"start_1_based_inclusive",
"end_1_based_inclusive",
"strand",
"qualifiers_json",
].join(delimiter),
...rows,
"",
].join("\n");
}
function buildVcf({
record,
scope,
selection,
tracks,
}: {
record: SequenceRecord;
scope: "all" | "selection" | "visible";
selection?: SequenceSelection;
tracks: Array<SequenceTrack>;
}): string {
const matchesActiveReference = (reference: string): boolean =>
normalizeVariantReference(reference) ===
normalizeVariantReference(record.sourceLabel) ||
normalizeVariantReference(reference) ===
normalizeVariantReference(record.id);
const variantIsInScope = (variant: NonNullable<SequenceTrack["variants"]>[number]): boolean => {
if (scope === "all") return true;
if (!matchesActiveReference(variant.reference)) return false;
if (scope !== "selection" || selection == null) return true;
return (
selection.recordId === record.id &&
selectionContainsCoordinate(selection, variant.position)
);
};
const variantTracks = tracks.filter(
(track) =>
track.kind === "variants" &&
(scope === "all" ||
(track.variants ?? []).some(variantIsInScope)),
);
const variants = variantTracks.flatMap((track) =>
(track.variants ?? [])
.filter(variantIsInScope)
.map((variant) => ({ track, variant })),
);
const metadata = mergeVcfMetadata({ scope, tracks: variantTracks, variants });
const sampleNames = [
...new Set(
variantTracks.flatMap((track) =>
track.vcfHeader?.sampleNames.length
? track.vcfHeader.sampleNames
: (track.variants ?? []).flatMap((variant) =>
Object.keys(variant.samples),
),
),
),
];
const includeFormat =
sampleNames.length > 0 ||
variantTracks.some(
(track) =>
track.vcfHeader?.columns[8] === "FORMAT" ||
(track.variants ?? []).some((variant) => variant.format != null),
);
const rows = variants.map(({ track, variant }) => {
const fields = [
variant.reference,
String(variant.position),
variant.rawId ?? variant.id,
variant.referenceAllele,
variant.alternateAlleles.join(",") || ".",
variant.rawQuality ?? String(variant.quality ?? "."),
variant.rawFilter ?? (variant.filters.join(";") || "PASS"),
variant.rawInfo ??
(Object.entries(variant.info)
.map(([key, value]) => (value === true ? key : `${key}=${value}`))
.join(";") || "."),
];
if (includeFormat) {
fields.push(variant.format ?? ".");
for (const sampleName of sampleNames) {
const originalIndex =
track.vcfHeader?.sampleNames.indexOf(sampleName) ?? -1;
fields.push(
(originalIndex < 0
? undefined
: variant.sampleValues?.[originalIndex]) ??
variant.samples[sampleName] ??
".",
);
}
}
return fields.join("\t");
});
const columns = [
"#CHROM",
"POS",
"ID",
"REF",
"ALT",
"QUAL",
"FILTER",
"INFO",
...(includeFormat ? ["FORMAT", ...sampleNames] : []),
];
return [
...metadata,
columns.join("\t"),
...rows,
"",
].join("\n");
}
function mergeVcfMetadata({
scope,
tracks,
variants,
}: {
scope: "all" | "selection" | "visible";
tracks: Array<SequenceTrack>;
variants: Array<{
track: SequenceTrack;
variant: NonNullable<SequenceTrack["variants"]>[number];
}>;
}): Array<string> {
const lines = tracks.flatMap((track) => track.vcfHeader?.metaLines ?? []);
const fileFormat =
lines.find((line) => line.startsWith("##fileformat=")) ??
"##fileformat=VCFv4.3";
const references = new Set(
variants.map(({ variant }) => normalizeVariantReference(variant.reference)),
);
const filters = new Set(
variants.flatMap(({ variant }) =>
variant.rawFilter == null
? variant.filters
: variant.rawFilter === "." || variant.rawFilter === "PASS"
? []
: variant.rawFilter.split(";"),
),
);
const info = new Set(
variants.flatMap(({ variant }) => Object.keys(variant.info)),
);
const formats = new Set(
variants.flatMap(({ variant }) => variant.format?.split(":") ?? []),
);
const alternateAlleles = new Set(
variants.flatMap(({ variant }) =>
variant.alternateAlleles.flatMap((allele) => {
const symbolic = /^<([^>]+)>$/u.exec(allele);
return symbolic?.[1] == null ? [] : [symbolic[1]];
}),
),
);
const neededMetadata = (line: string): boolean => {
if (scope === "all") return true;
const definition =
/^##(contig|FILTER|INFO|FORMAT|ALT)=<ID=([^,>]+)/u.exec(line);
if (definition == null) return true;
const [, kind, id] = definition;
if (id == null) return false;
if (kind === "contig") return references.has(normalizeVariantReference(id));
if (kind === "FILTER") return filters.has(id);
if (kind === "INFO") return info.has(id);
if (kind === "FORMAT") return formats.has(id);
return alternateAlleles.has(id);
};
return [
fileFormat,
...new Set(
lines.filter(
(line) => !line.startsWith("##fileformat=") && neededMetadata(line),
),
),
];
}
function normalizeVariantReference(reference: string): string {
return reference.toLowerCase().replace(/^chr/u, "");
}
function formatFeatureLocation(feature: SequenceFeature): string {
const segments = feature.segments ?? [
{ end: feature.end, start: feature.start },
];
const body =
segments.length === 1
? formatSegment(segments[0])
: `join(${segments.map(formatSegment).join(",")})`;
return feature.strand === "-" ? `complement(${body})` : body;
}
function formatSegment(
segment: NonNullable<SequenceFeature["segments"]>[number] | undefined,
): string {
if (segment == null) return "1..1";
const prefix = segment.remoteAccession ? `${segment.remoteAccession}:` : "";
return `${prefix}${segment.partialStart ? "<" : ""}${segment.start}..${
segment.partialEnd ? ">" : ""
}${segment.end}`;
}
function featuresInScope(
record: SequenceRecord,
selection: SequenceSelection | undefined,
scope: "all" | "selection" | "visible",
): Array<SequenceFeature> {
if (scope !== "selection" || selection == null) return record.features;
return record.features.filter((feature) =>
selectionOverlapsFeature(selection, feature),
);
}
function wrap(value: string, width: number): Array<string> {
const lines: Array<string> = [];
for (let index = 0; index < value.length; index += width) {
lines.push(value.slice(index, index + width));
}
return lines;
}
function sanitizeFileName(value: string): string {
return value
.replaceAll(/[^A-Za-z0-9._-]+/gu, "-")
.replaceAll(/^-+|-+$/gu, "");
}
function escapeQualifier(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function escapeTsv(value: string): string {
return neutralizeSpreadsheetFormula(
value.replaceAll(/[\t\r\n]+/gu, " "),
);
}
function escapeXml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function svgFeatureColor(type: string): string {
const normalized = type.toLowerCase();
if (normalized === "cds") return "#2563eb";
if (normalized.includes("gene")) return "#7c3aed";
if (normalized.includes("promoter")) return "#d97706";
if (normalized.includes("repeat")) return "#db2777";
return "#0d9488";
}
function quoteDelimited(value: string, delimiter: string): string {
const safeValue = neutralizeSpreadsheetFormula(value);
return safeValue.includes(delimiter) || /["\r\n]/u.test(safeValue)
? `"${safeValue.replaceAll('"', '""')}"`
: safeValue;
}
SHA-256: ec53063b81e277e9d0b42f7a0d0f7ed4cb75b651da446f97eb448a2a7ac1c98f