Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/ab1-raw-data-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@platforma-open/milaboratories.fastq-reader': minor
'@platforma-open/milaboratories.fastq-reader.model': minor
'@platforma-open/milaboratories.fastq-reader.ui': minor
'@platforma-open/milaboratories.fastq-reader.workflow': minor
---

Rename to Raw Data Reader and add Sanger AB1 support: pick one trace of a sample to view its chromatogram (dye traces, base calls, quality) or base-call sequence, and download it as .ab1 or FASTQ. The whole dataset downloads in its original format.
7 changes: 5 additions & 2 deletions block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,19 @@
"ui": "@platforma-open/milaboratories.fastq-reader.ui/dist"
},
"meta": {
"title": "FASTQ Reader",
"title": "Raw Data Reader",
"logo": "file:../logos/block-logo.png",
"url": "https://github.com/platforma-open/milaboratories.fastq-reader",
"support": "mailto:support@milaboratories.com",
"description": "Inspect raw reads of an imported FASTQ/FASTA dataset, one sample at a time.",
"description": "Inspect raw reads of an imported FASTQ/FASTA dataset, or the Sanger traces of an AB1 dataset, one sample at a time.",
"longDescription": "file:../docs/description.md",
"changelog": "file:../CHANGELOG.md",
"tags": [
"fastq",
"fasta",
"ab1",
"sanger",
"chromatogram",
"reads",
"qc",
"viewer"
Expand Down
27 changes: 23 additions & 4 deletions docs/description.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
# FASTQ Reader
# Raw Data Reader

Inspect the raw reads of a sequencing dataset imported with **Samples & Data**.
Inspect the raw data of a sequencing dataset imported with **Samples & Data**: the reads of a
FASTQ/FASTA dataset, or the Sanger chromatograms of an AB1 dataset.

## FASTQ / FASTA

Pick a dataset and a sample, choose which reads to look at, and see them rendered exactly as
they appear in the file. The block streams the input and reads only what it needs — it never
loads or outputs whole files, so it stays fast on multi-gigabyte FASTQ inputs.

## Read selection
### Read selection

- **Range** — a number of reads starting from a given position, optionally **randomized**
across the file instead of taken sequentially.
- **Read numbers** — specific reads by their 1-based position.
- **Read headers** — reads whose header matches the text you provide.
- **Sequence pattern** — reads whose sequence contains a given subsequence.

## Viewing
### Viewing

- For paired-end data, view **R1**, **R2**, or both mates side by side.
- Toggle between the **full record** (header, sequence, and quality) and **sequence only**.

## Sanger AB1

Pick an AB1 dataset and a sample, then choose one of the sample's traces (for example the
forward or reverse primer read). No Run is needed — the trace opens as soon as it is selected.

- **Chromatogram** — the four dye traces with base calls and per-base quality bars, at three
zoom levels.
- **Sequence** — the base calls, with bases below Q20 dimmed, plus run details (instrument,
basecaller, run date) and quality summary.
- Download the trace as the original **.ab1** file or as **FASTQ** (base calls + qualities).

## Downloads

The original files — for the selected sample or the whole dataset — can be downloaded in their
original format as soon as a dataset is selected, without running the block.
117 changes: 112 additions & 5 deletions model/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ const MAX_READS = 5000;
/** Default number of reads scanned for search / gzip-random modes (see workflow). */
const DEFAULT_SCAN_CAP = 2_000_000;

const FASTQ_EXTENSIONS = new Set(["fastq", "fastq.gz", "fasta", "fasta.gz"]);
const READ_EXTENSIONS = new Set(["fastq", "fastq.gz", "fasta", "fasta.gz"]);
const FASTA_EXTENSIONS = new Set(["fasta", "fasta.gz"]);
/** Sanger chromatograms — one trace per file, parsed and shown by the UI. */
const AB1_EXTENSION = "ab1";
const READ_INDEX_AXIS = "pl7.app/sequencing/readIndex";
const TAG_AXIS = "pl7.app/sequencing/tag";

export const BLOCK_TITLE = "Raw Data Reader";

/** Identity of a dataset option, for the snapshot lookups below. */
export const refKey = (ref: PlRef): string => `${ref.blockId}/${ref.name}`;

/**
* Read indices a dataset exposes. fastq variants carry a `pl7.app/sequencing/readIndex`
Expand Down Expand Up @@ -46,6 +54,12 @@ export type BlockData = {
// Human-readable label of the selected sample, stored by the UI on selection.
// The subtitle reads this (the sidebar render ctx can't resolve labels itself).
sampleLabel?: string;
// Snapshot of "the picked dataset is AB1", written by the UI together with
// inputRef (args may read only data, and AB1 datasets have nothing to run).
inputIsAb1?: boolean;
// AB1: the trace being shown — JSON array of the sample's tag values (the
// pre-run's `extra` key parts). Unset or stale → the sample's first trace.
traceKey?: string;

selectionMode: SelectionMode;
// range mode
Expand All @@ -63,6 +77,9 @@ export type BlockData = {
pairedView: "R1" | "R2" | "both";
contentView: "full" | "sequence";
settingsOpen: boolean;
// AB1 view-only state
ab1View?: "chromatogram" | "sequence";
ab1Zoom?: "compact" | "normal" | "wide";
};

/** Projected workflow input. */
Expand Down Expand Up @@ -99,6 +116,9 @@ export type ReadsResult = {

export type SampleOption = { value: string; label: string };

/** One AB1 trace of the selected sample. `value` is the `traceKey`. */
export type TraceOption = { value: string; label: string };

/**
* One wrapped original file, as published by the pre-run's `fileIndex`. `extra`
* holds the key parts that are neither the sample id nor the read index (e.g.
Expand Down Expand Up @@ -135,6 +155,9 @@ function collectRawExports(
const spec = ctx.resultPool.getPColumnSpecByRef(ctx.data.inputRef);
const ext = spec?.domain?.["pl7.app/fileExtension"] ?? "fastq";
const labels = spec ? (ctx.resultPool.findLabels(spec.axesSpec[0]) ?? {}) : {};
// The pre-run keys files of datasets without a read-index axis (fasta, ab1)
// as "R1"; that is not part of the data, so keep it out of the file names.
const hasReadIndex = spec?.axesSpec.some((a) => a.name === READ_INDEX_AXIS) ?? true;

// All or nothing: handles may be absent while the pre-run is still computing,
// and publishing only the ready ones would let the user save an archive that
Expand All @@ -151,7 +174,7 @@ function collectRawExports(
const used = new Set<string>();
return resolved.map(({ entry, handle }) => {
const base = [String(labels[entry.sampleId] ?? entry.sampleId), ...(entry.extra ?? [])]
.concat(entry.readIndex)
.concat(hasReadIndex ? [entry.readIndex] : [])
.map(safeNamePart)
.filter((part) => part !== "")
.join("_");
Expand All @@ -170,6 +193,55 @@ function collectRawExports(
});
}

/** Whether a dataset column holds AB1 traces. */
const isAb1Spec = (spec: { domain?: Record<string, string> } | undefined): boolean =>
spec?.domain?.["pl7.app/fileExtension"] === AB1_EXTENSION;

/**
* The AB1 traces of the selected sample, from the pre-run's `fileIndex`. A
* sample can hold several traces (e.g. forward and reverse primer); they are
* told apart by the tag axes, whose values are the entry's `extra` parts.
*/
function sampleTraces(
ctx: BlockRenderCtx<BlockArgs, BlockData>,
): { option: TraceOption; field: string }[] | undefined {
const { inputRef, sampleId } = ctx.data;
if (!inputRef || !sampleId || !ctx.prerun) return undefined;
const spec = ctx.resultPool.getPColumnSpecByRef(inputRef);
if (!isAb1Spec(spec)) return undefined;

const index = ctx.prerun
.resolve({ field: "fileIndex", allowPermanentAbsence: true })
?.getDataAsJson<RawFileIndexEntry[]>();
if (!index) return undefined;

// Tag names, in the order the key parts follow (axes after the sample axis).
const tagNames = (spec?.axesSpec ?? [])
.slice(1)
.map((a) => a.domain?.[TAG_AXIS] ?? a.annotations?.["pl7.app/label"] ?? a.name);

return index
.filter((e) => e.sampleId === sampleId)
.map((e) => {
const parts = e.extra ?? [];
const label =
parts.length === 0
? "Trace"
: parts.length === 1
? parts[0]
: parts.map((v, i) => `${tagNames[i] ?? `Tag ${i + 1}`}: ${v}`).join(", ");
return { option: { value: JSON.stringify(parts), label }, field: e.field };
})
.sort((a, b) => a.option.label.localeCompare(b.option.label));
}

/** The trace the user picked, or the sample's first one if none / stale. */
function selectedTrace(ctx: BlockRenderCtx<BlockArgs, BlockData>) {
const traces = sampleTraces(ctx);
if (!traces || traces.length === 0) return undefined;
return traces.find((t) => t.option.value === ctx.data.traceKey) ?? traces[0];
}

const parseNumbers = (text: string): number[] => {
const out = new Set<number>();
for (const tok of text.split(/[\s,]+/)) {
Expand Down Expand Up @@ -210,6 +282,9 @@ export const platforma = BlockModelV3.create(dataModel)

.args<BlockArgs>((data) => {
if (!data.inputRef) throw new Error("Select a dataset");
// AB1 traces are parsed and shown by the UI straight from the pre-run's
// file handles — there is nothing for the main workflow to compute.
if (data.inputIsAb1) throw new Error("AB1 traces are shown without running the block");
if (!data.sampleId) throw new Error("Select a sample");

const mode = data.selectionMode;
Expand Down Expand Up @@ -260,7 +335,7 @@ export const platforma = BlockModelV3.create(dataModel)
return { inputRef: data.inputRef };
})

// Dataset dropdown — same filter as fastqc, widened to fasta.
// Dataset dropdown — same filter as fastqc, widened to fasta and AB1.
.output("inputOptions", (ctx) =>
ctx.resultPool.getOptions((v) => {
if (!isPColumnSpec(v)) return false;
Expand All @@ -269,11 +344,43 @@ export const platforma = BlockModelV3.create(dataModel)
v.name === "pl7.app/sequencing/data" &&
(v.valueType as string) === "File" &&
ext !== undefined &&
FASTQ_EXTENSIONS.has(ext)
(READ_EXTENSIONS.has(ext) || ext === AB1_EXTENSION)
);
}),
)

// Which dataset options are AB1, keyed by `refKey`. The UI snapshots the
// picked one into `data.inputIsAb1` on selection, for the args lambda.
.output("ab1Inputs", (ctx): string[] =>
ctx.resultPool
.getOptions((v) => isPColumnSpec(v) && v.name === "pl7.app/sequencing/data" && isAb1Spec(v))
.map((o) => refKey(o.ref)),
)

// Whether the selected dataset holds AB1 traces — switches the UI to the
// chromatogram viewer. Read from the spec, so it's always current.
.output("isAb1", (ctx): boolean | undefined => {
if (!ctx.data.inputRef) return undefined;
return isAb1Spec(ctx.resultPool.getPColumnSpecByRef(ctx.data.inputRef));
})

// AB1: the selected sample's traces, for the trace picker.
.output("traceOptions", (ctx): TraceOption[] | undefined =>
sampleTraces(ctx)?.map((t) => t.option),
)

// AB1: the trace being shown — its key, label, and a local handle to the
// file's bytes, which the UI fetches and parses (one file, a few hundred KB).
// Only the shown trace is downloaded; the rest stay on the backend.
.output("selectedTrace", (ctx) => {
const trace = selectedTrace(ctx);
if (!trace) return undefined;
const file = ctx.prerun
?.resolve({ field: trace.field, allowPermanentAbsence: true })
?.getFileHandle();
return { key: trace.option.value, label: trace.option.label, file };
})

// Sample dropdown — only the samples that belong to the chosen dataset.
// `findLabels` is project-wide (every value of the sample-id axis across all
// datasets), so we intersect it with this dataset's own sample ids, which the
Expand Down Expand Up @@ -364,7 +471,7 @@ export const platforma = BlockModelV3.create(dataModel)
// the platform substitutes the literal string "Invalid title" — which is what
// this block used to show. Keep the title constant and put the per-instance
// detail in the subtitle, read defensively from `data` only.
.title(() => "FASTQ Reader")
.title(() => BLOCK_TITLE)

// Sample's human-readable label, stored by the UI on selection (the sidebar
// context can't resolve axis labels itself).
Expand Down
Loading
Loading