From 0058dc5e3e349dedf2e481f8be17c7a82defe19a Mon Sep 17 00:00:00 2001 From: Mark Izraelson Date: Thu, 8 Oct 2026 10:34:12 -0400 Subject: [PATCH 1/3] Add Sanger AB1 support and rename to Raw Data Reader AB1 datasets from Samples & Data (TaggedAb1) are now selectable. A sample's traces are listed from the pre-run's file index (told apart by their tag values); the selected trace's bytes are fetched from the pre-run's exported file and parsed in the UI, so no Run is needed. The viewer shows the four dye traces with base calls and quality bars, or the base calls as text, with run metadata and .ab1 / FASTQ downloads of the trace. Args refuse AB1 datasets (snapshot of the dataset kind taken on pick) and the main workflow asserts the same. Raw-file downloads drop the synthetic "R1" from file names of datasets without a read-index axis. Bump block-tools to 2.16.13 and tengo-builder to 4.1.11 for the require-latest CI preflight. --- block/package.json | 7 +- docs/description.md | 27 ++- model/src/index.ts | 117 ++++++++- pnpm-lock.yaml | 84 +++---- pnpm-workspace.yaml | 4 +- ui/src/ab1.ts | 219 +++++++++++++++++ ui/src/download.ts | 122 ++++++++++ ui/src/pages/Ab1Viewer.vue | 420 +++++++++++++++++++++++++++++++++ ui/src/pages/MainPage.vue | 7 +- ui/src/pages/RawDownloads.vue | 86 +++++++ ui/src/pages/ReadViewer.vue | 198 +--------------- ui/src/pages/SettingsPanel.vue | 150 ++++++------ workflow/src/main.tpl.tengo | 5 +- workflow/src/prerun.tpl.tengo | 7 +- 14 files changed, 1135 insertions(+), 318 deletions(-) create mode 100644 ui/src/ab1.ts create mode 100644 ui/src/download.ts create mode 100644 ui/src/pages/Ab1Viewer.vue create mode 100644 ui/src/pages/RawDownloads.vue diff --git a/block/package.json b/block/package.json index 379a2f4..a925860 100644 --- a/block/package.json +++ b/block/package.json @@ -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" diff --git a/docs/description.md b/docs/description.md index 074ab8a..461b746 100644 --- a/docs/description.md +++ b/docs/description.md @@ -1,12 +1,15 @@ -# 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. @@ -14,7 +17,23 @@ loads or outputs whole files, so it stays fast on multi-gigabyte FASTQ inputs. - **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. diff --git a/model/src/index.ts b/model/src/index.ts index 7b8fe31..5606670 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -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` @@ -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 @@ -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. */ @@ -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. @@ -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 @@ -151,7 +174,7 @@ function collectRawExports( const used = new Set(); 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("_"); @@ -170,6 +193,55 @@ function collectRawExports( }); } +/** Whether a dataset column holds AB1 traces. */ +const isAb1Spec = (spec: { domain?: Record } | 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, +): { 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(); + 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) { + 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(); for (const tok of text.split(/[\s,]+/)) { @@ -210,6 +282,9 @@ export const platforma = BlockModelV3.create(dataModel) .args((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; @@ -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; @@ -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 @@ -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). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 097176b..d16f529 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,14 +19,14 @@ catalogs: specifier: 1.7.8 version: 1.7.8 '@platforma-sdk/block-tools': - specifier: 2.16.4 - version: 2.16.4 + specifier: 2.16.13 + version: 2.16.13 '@platforma-sdk/model': specifier: 1.80.2 version: 1.80.2 '@platforma-sdk/tengo-builder': - specifier: 4.1.4 - version: 4.1.4 + specifier: 4.1.11 + version: 4.1.11 '@platforma-sdk/test': specifier: 1.80.5 version: 1.80.5 @@ -71,7 +71,7 @@ importers: version: 1.6.0(@types/node@26.0.1)(esbuild@0.28.1)(rollup@4.62.2)(vue@3.5.39(typescript@6.0.3))(yaml@2.9.0) '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.16.4(@types/node@26.0.1) + version: 2.16.13(@types/node@26.0.1) shx: specifier: 'catalog:' version: 0.4.0 @@ -98,7 +98,7 @@ importers: version: link:../workflow '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.16.4(@types/node@26.0.1) + version: 2.16.13(@types/node@26.0.1) '@platforma-sdk/model': specifier: 'catalog:' version: 1.80.2 @@ -129,7 +129,7 @@ importers: version: 1.3.0 '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.16.4(@types/node@26.0.1) + version: 2.16.13(@types/node@26.0.1) software: devDependencies: @@ -138,7 +138,7 @@ importers: version: 1.7.8 '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.16.4(@types/node@26.0.1) + version: 2.16.13(@types/node@26.0.1) test: dependencies: @@ -201,7 +201,7 @@ importers: devDependencies: '@platforma-sdk/tengo-builder': specifier: 'catalog:' - version: 4.1.4 + version: 4.1.11 '@platforma-sdk/test': specifier: 'catalog:' version: 1.80.5(@bytecodealliance/preview2-shim@0.17.8)(@types/node@26.0.1)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0)) @@ -1030,8 +1030,8 @@ packages: resolution: {integrity: sha512-+0ih1njQn4wvLjGmFKboKFRlWYLrzg7QdRK2aNo8ivTKY16TrsMRCk0GQEt0aPKELnv7QSrsNSjg7e7GSdGMDQ==} engines: {node: '>=22.19.0'} - '@milaboratories/pl-client@3.17.3': - resolution: {integrity: sha512-O0Zddg0aBXv4G1rcw+pjL+4F9cPbk+NfIKPQ0d9HVI4cjiWLR2eBK4fzwdolqAlq+Go9frSJ/UzBMNMgWUQytw==} + '@milaboratories/pl-client@3.18.2': + resolution: {integrity: sha512-pzbPgxEI1sDLUt5A9v78ubroolxoC6y95Ldq7ceibXqXg87O10eUMi9/LbCMWYJu2y/mxj2kR4vSEhdlHCU+aA==} engines: {node: '>=22.19.0'} '@milaboratories/pl-config@1.8.4': @@ -1065,8 +1065,8 @@ packages: '@milaboratories/pl-model-backend@1.4.16': resolution: {integrity: sha512-MrJwM3aOzrqr3+GVpS/1Zh/SOH/2tBtoyk/+eE6C7LRjofoip8GIF4be8AgPAM3C+6wTSGrQ5x3CM6A8Ql2HVA==} - '@milaboratories/pl-model-backend@1.4.29': - resolution: {integrity: sha512-A4boh0VStHBoIlM54DqN2JHu+R10+/KVXY6mnDQd4F3wWuKlssCE2yLGoY3TIpeWCgFME7rLtei5ZLwYjV81eg==} + '@milaboratories/pl-model-backend@1.4.36': + resolution: {integrity: sha512-CMTkZPy4eG+bociJDKEX13vt/3JdThbOCIW4X/uD06R0M4W8ns16YukrEHX563peq6zN8qLKKuKdt7Kws4/g+Q==} '@milaboratories/pl-model-common@1.46.2': resolution: {integrity: sha512-VEeauisApYScvCS8lnK3zpFJ520xuTAodKJmjR8ulHcMrWMyWMfHEdGb7j5OMD0mM/OwTgmQrrJ5eB7Xd+xoOQ==} @@ -1074,8 +1074,8 @@ packages: '@milaboratories/pl-model-common@1.47.1': resolution: {integrity: sha512-5F9I8JvUPRJ2HEzz5MmTio6JFYnhYoMVHfToh2NyXyOyQgAF6jhOzZBOsrEig9NfCPZBRB7v6UjmIK43dG4o/g==} - '@milaboratories/pl-model-common@1.51.0': - resolution: {integrity: sha512-KowoSl3BTUo8N4yUYpFiIdi7JZmtGX2bm4roK9maeoGdB3Tb0SsfySsWgdDSGwWMvxHwQ6cp3DA2dkw1dFlmcg==} + '@milaboratories/pl-model-common@1.53.0': + resolution: {integrity: sha512-9YrKHA+fQPFicLf7+t3dhS4QsYO2ZpgiufjnRup2nwKJRqNqlMz1v3vFRVA7LiU8lbqJaVChPNAu2bFqrWOgdg==} '@milaboratories/pl-model-middle-layer@1.30.13': resolution: {integrity: sha512-owQVozyub/6aKgk+yce+9sDK+t0HrpLbnlKJqQjYJahuhoju8j6VMY17AWgvADSDCiI3gnp/Tm/I4Ur6ySs1Cg==} @@ -1083,8 +1083,8 @@ packages: '@milaboratories/pl-model-middle-layer@1.30.7': resolution: {integrity: sha512-rs9x3Ron4ujR/UOdEgB8WUB1SvZ8ZAScT1Av/e4or+iiQ/CzhmK9nqtYamHVwKV+JgwIFbXQwxGvIHDVz955dQ==} - '@milaboratories/pl-model-middle-layer@1.34.0': - resolution: {integrity: sha512-t3J7JqtJZDezMVRRx6tZb6DsIiDJJKi2OmuNKdWGEwuXTWEPyJN2FG8SH88+wuH0+ex3ZZwcD+MgI0RThvyXgg==} + '@milaboratories/pl-model-middle-layer@1.34.2': + resolution: {integrity: sha512-onccOI/HLRkUxmjEyrGVgKsERsID5GsPu6HiMHSaC8/tzVOPNDSfDVcXBrWQmUHCNXgia+iLuhqQKyFRx7ZKwA==} '@milaboratories/pl-tree@1.13.1': resolution: {integrity: sha512-NtVVUSF9eJA5v5iUEfGgKl0JbG/EIvy+YCsxzCZ1dXG1S3gCNwt01Nj5PkWW7FkDG1IT0ZfwyeO0mVVz2kIv1Q==} @@ -1703,8 +1703,8 @@ packages: resolution: {integrity: sha512-a13K0hcXYf+sgx+07QFjIu2uTeTH5lhHHI1o563kvs9Z5vssU9jn7HFgXgSe6TMjDntERJcGHiGm8nUd4t3FiA==} hasBin: true - '@platforma-sdk/block-tools@2.16.4': - resolution: {integrity: sha512-nLNajwq4bTkFjIgHkB1TMfJBWw6sAW+x1rnSqKU2eBBFNhNi/iAuApDMGDwqY3RLU+7wtL7PBG+ivC/cbx0ALA==} + '@platforma-sdk/block-tools@2.16.13': + resolution: {integrity: sha512-gC6v6Xko0m5VnZUaz9mCpR2AZh1MT0od4mAy5hDRQ2z2qBrQBXchQwNhAbffCYYfqFAf1njjAzWV33EQsIfR1g==} hasBin: true '@platforma-sdk/blocks-deps-updater@2.2.0': @@ -1717,11 +1717,11 @@ packages: '@platforma-sdk/package-builder-lib@1.2.0': resolution: {integrity: sha512-AtSzaZ39BGlqcyYtpGREDz+YlGCDdSTQJNV/+NkTyUtoq1ECRO+iMqAX1DSSza1WqylnURX8vKVI2owS/cxntA==} - '@platforma-sdk/package-builder-lib@1.4.0': - resolution: {integrity: sha512-ORcuPJ5SM1RiQAOUVM7alWDbOWQ/gUTgLDToP/yAs2uHXhKNvXphgDQTamCX6cseMZ0uKdWzTudJAQAJM1Kvwg==} + '@platforma-sdk/package-builder-lib@1.4.1': + resolution: {integrity: sha512-n85dVGndxqkbNodoNJLUHBPnPquOSo3jcjY4Beq892UpMW/1u7IxkOnMR4DygcOXrjXP9zDt/MbV6LB1f9kmtg==} - '@platforma-sdk/tengo-builder@4.1.4': - resolution: {integrity: sha512-vYGuYnbQqnzmpxewixh+O/eAtc8ai6SjLoYux9dBmuIHSEzzlfJdGsJO1V6yR+BrZ1DRX0ZIY3jVR1KBnur7qg==} + '@platforma-sdk/tengo-builder@4.1.11': + resolution: {integrity: sha512-iHdL9YCXZur/NXoHsIDS1OW/RaDm/5bxUU62wUx9orql7AFguSMFCv6BoKUAS8+iUiz7Ibcd5Sp/gACeFqGyZw==} engines: {node: '>=22'} hasBin: true @@ -6062,11 +6062,11 @@ snapshots: utility-types: 3.11.0 yaml: 2.9.0 - '@milaboratories/pl-client@3.17.3': + '@milaboratories/pl-client@3.18.2': dependencies: '@grpc/grpc-js': 1.14.5 '@milaboratories/pl-http': 1.2.4 - '@milaboratories/pl-model-common': 1.51.0 + '@milaboratories/pl-model-common': 1.53.0 '@milaboratories/ts-helpers': 1.8.6 '@protobuf-ts/grpc-transport': 2.11.1(@grpc/grpc-js@1.14.5) '@protobuf-ts/runtime': 2.11.1 @@ -6199,9 +6199,9 @@ snapshots: canonicalize: 2.1.0 zod: 3.25.76 - '@milaboratories/pl-model-backend@1.4.29': + '@milaboratories/pl-model-backend@1.4.36': dependencies: - '@milaboratories/pl-client': 3.17.3 + '@milaboratories/pl-client': 3.18.2 canonicalize: 2.1.0 zod: 3.25.76 @@ -6220,7 +6220,7 @@ snapshots: es-toolkit: 1.49.0 zod: 3.25.76 - '@milaboratories/pl-model-common@1.51.0': + '@milaboratories/pl-model-common@1.53.0': dependencies: '@milaboratories/helpers': 1.14.5 '@milaboratories/pl-error-like': 1.12.10 @@ -6244,10 +6244,10 @@ snapshots: utility-types: 3.11.0 zod: 3.25.76 - '@milaboratories/pl-model-middle-layer@1.34.0': + '@milaboratories/pl-model-middle-layer@1.34.2': dependencies: '@milaboratories/helpers': 1.14.5 - '@milaboratories/pl-model-common': 1.51.0 + '@milaboratories/pl-model-common': 1.53.0 es-toolkit: 1.49.0 utility-types: 3.11.0 zod: 3.25.76 @@ -6282,7 +6282,7 @@ snapshots: oxlint: 1.63.0 oxlint-plugin-eslint: 1.63.0 rolldown: 1.1.3 - rolldown-plugin-dts: 0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.9.3)) + rolldown-plugin-dts: 0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.6.3)) rollup-plugin-copy: 3.5.0 rollup-plugin-sourcemaps2: 0.5.8(@types/node@26.0.1)(rollup@4.62.2) typescript: 5.9.3 @@ -6323,7 +6323,7 @@ snapshots: oxlint: 1.63.0 oxlint-plugin-eslint: 1.63.0 rolldown: 1.1.3 - rolldown-plugin-dts: 0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.9.3)) + rolldown-plugin-dts: 0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.6.3)) rollup-plugin-copy: 3.5.0 rollup-plugin-sourcemaps2: 0.5.8(@types/node@26.0.1)(rollup@4.62.2) typescript: 5.9.3 @@ -6364,7 +6364,7 @@ snapshots: oxlint: 1.63.0 oxlint-plugin-eslint: 1.63.0 rolldown: 1.1.3 - rolldown-plugin-dts: 0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.9.3)) + rolldown-plugin-dts: 0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.6.3)) rollup-plugin-copy: 3.5.0 rollup-plugin-sourcemaps2: 0.5.8(@types/node@26.0.1)(rollup@4.62.2) typescript: 5.9.3 @@ -6872,19 +6872,19 @@ snapshots: - bare-buffer - react-native-b4a - '@platforma-sdk/block-tools@2.16.4(@types/node@26.0.1)': + '@platforma-sdk/block-tools@2.16.13(@types/node@26.0.1)': dependencies: '@aws-sdk/client-ecr-public': 3.859.0 '@aws-sdk/client-s3': 3.859.0 '@inquirer/prompts': 7.10.1(@types/node@26.0.1) '@milaboratories/pl-http': 1.2.4 - '@milaboratories/pl-model-backend': 1.4.29 - '@milaboratories/pl-model-common': 1.51.0 - '@milaboratories/pl-model-middle-layer': 1.34.0 + '@milaboratories/pl-model-backend': 1.4.36 + '@milaboratories/pl-model-common': 1.53.0 + '@milaboratories/pl-model-middle-layer': 1.34.2 '@milaboratories/resolve-helper': 1.1.3 '@milaboratories/ts-helpers': 1.8.6 '@platforma-sdk/blocks-deps-updater': 2.2.0 - '@platforma-sdk/package-builder-lib': 1.4.0 + '@platforma-sdk/package-builder-lib': 1.4.1 canonicalize: 2.1.0 commander: 15.0.0 lru-cache: 11.5.1 @@ -6934,7 +6934,7 @@ snapshots: - bare-buffer - react-native-b4a - '@platforma-sdk/package-builder-lib@1.4.0': + '@platforma-sdk/package-builder-lib@1.4.1': dependencies: '@aws-sdk/client-s3': 3.859.0 '@aws-sdk/lib-storage': 3.859.0(@aws-sdk/client-s3@3.859.0) @@ -6950,9 +6950,9 @@ snapshots: - bare-buffer - react-native-b4a - '@platforma-sdk/tengo-builder@4.1.4': + '@platforma-sdk/tengo-builder@4.1.11': dependencies: - '@milaboratories/pl-model-backend': 1.4.29 + '@milaboratories/pl-model-backend': 1.4.36 '@milaboratories/resolve-helper': 1.1.3 '@milaboratories/tengo-tester': 1.6.4 '@milaboratories/ts-helpers': 1.8.6 @@ -9411,7 +9411,7 @@ snapshots: reusify@1.1.0: {} - rolldown-plugin-dts@0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.9.3)): + rolldown-plugin-dts@0.26.0(rolldown@1.1.3)(typescript@5.9.3)(vue-tsc@3.3.5(typescript@5.6.3)): dependencies: '@babel/generator': 8.0.0 '@babel/helper-validator-identifier': 8.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a842143..cbe0ab7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,11 +10,11 @@ catalog: "@milaboratories/ts-builder": 1.6.0 "@milaboratories/ts-configs": 1.3.0 "@platforma-sdk/workflow-tengo": 6.7.2 - "@platforma-sdk/block-tools": 2.16.4 + "@platforma-sdk/block-tools": 2.16.13 "@platforma-sdk/model": 1.80.2 "@platforma-sdk/ui-vue": 1.80.4 "@platforma-sdk/test": 1.80.5 - "@platforma-sdk/tengo-builder": 4.1.4 + "@platforma-sdk/tengo-builder": 4.1.11 "@platforma-sdk/package-builder": 3.14.1 "@platforma-sdk/blocks-deps-updater": 2.2.0 diff --git a/ui/src/ab1.ts b/ui/src/ab1.ts new file mode 100644 index 0000000..f5d24bc --- /dev/null +++ b/ui/src/ab1.ts @@ -0,0 +1,219 @@ +// Minimal reader for ABIF (.ab1) Sanger chromatogram files. +// +// ABIF is a big-endian tagged container: a 128-byte header whose root entry +// points at a directory of 28-byte entries, each naming a tag (4 chars + a +// number) and where its data lives. Payloads of 4 bytes or less are stored +// inline in the entry's offset field. Only the tags a viewer needs are read — +// base calls, qualities, peak positions and the four analyzed dye traces. +// Format reference: Applied Biosystems "ABIF File Format" specification. + +export type Ab1Base = "A" | "C" | "G" | "T"; + +export type Ab1Trace = { + /** Base calls (edited calls `PBAS2` when present, else the original `PBAS1`). */ + sequence: string; + /** Per-base Phred quality, same length as `sequence`. Empty if the file has none. */ + quality: number[]; + /** Scan index of each base call's peak, same length as `sequence`. */ + peaks: number[]; + /** Analyzed dye intensities per base, all of equal length (number of scans). */ + traces: Record; + /** Number of scans in each trace. */ + scanCount: number; + /** Free-form run metadata — sample name, instrument, run date, basecaller. */ + meta: { label: string; value: string }[]; +}; + +type DirEntry = { + elementType: number; + elementSize: number; + numElements: number; + dataSize: number; + dataOffset: number; + /** Absolute byte offset of the entry itself — inline payloads live at +20. */ + entryOffset: number; +}; + +const ROOT_ENTRY_OFFSET = 6; +const ENTRY_SIZE = 28; + +export class Ab1ParseError extends Error {} + +function readDirectory(view: DataView): Map { + if (view.byteLength < ROOT_ENTRY_OFFSET + ENTRY_SIZE) + throw new Ab1ParseError("File is too short to be an AB1 trace."); + const magic = String.fromCharCode( + view.getUint8(0), + view.getUint8(1), + view.getUint8(2), + view.getUint8(3), + ); + if (magic !== "ABIF") throw new Ab1ParseError("Not an AB1 file (missing ABIF signature)."); + + const count = view.getInt32(ROOT_ENTRY_OFFSET + 12); + const dirOffset = view.getInt32(ROOT_ENTRY_OFFSET + 20); + if (count < 0 || dirOffset < 0 || dirOffset + count * ENTRY_SIZE > view.byteLength) + throw new Ab1ParseError("AB1 directory points outside the file."); + + const entries = new Map(); + for (let i = 0; i < count; i++) { + const off = dirOffset + i * ENTRY_SIZE; + const name = String.fromCharCode( + view.getUint8(off), + view.getUint8(off + 1), + view.getUint8(off + 2), + view.getUint8(off + 3), + ); + const num = view.getInt32(off + 4); + entries.set(`${name}${num}`, { + elementType: view.getInt16(off + 8), + elementSize: view.getInt16(off + 10), + numElements: view.getInt32(off + 12), + dataSize: view.getInt32(off + 16), + dataOffset: view.getInt32(off + 20), + entryOffset: off, + }); + } + return entries; +} + +/** Byte range of an entry's payload, resolving the inline (≤ 4 bytes) case. */ +function payload(view: DataView, e: DirEntry): { start: number; size: number } { + const start = e.dataSize <= 4 ? e.entryOffset + 20 : e.dataOffset; + if (start < 0 || start + e.dataSize > view.byteLength) + throw new Ab1ParseError("AB1 tag data points outside the file."); + return { start, size: e.dataSize }; +} + +function readBytes(view: DataView, e: DirEntry): Uint8Array { + const { start, size } = payload(view, e); + return new Uint8Array(view.buffer, view.byteOffset + start, size); +} + +function readChars(view: DataView, e: DirEntry): string { + let s = ""; + for (const b of readBytes(view, e)) s += String.fromCharCode(b); + return s; +} + +function readShorts(view: DataView, e: DirEntry): Int16Array { + const { start } = payload(view, e); + const out = new Int16Array(e.numElements); + for (let i = 0; i < e.numElements; i++) out[i] = view.getInt16(start + i * 2); + return out; +} + +/** Strings: type 18 = Pascal string (length byte first), 19 = C string, 2 = char array. */ +function readString(view: DataView, e: DirEntry): string { + const bytes = readBytes(view, e); + let body = bytes; + if (e.elementType === 18) body = bytes.subarray(1, 1 + bytes[0]); + let s = ""; + for (const b of body) { + if (b === 0) break; + s += String.fromCharCode(b); + } + return s.trim(); +} + +function readDate(view: DataView, e: DirEntry): string { + const { start } = payload(view, e); + const y = view.getInt16(start); + const m = view.getUint8(start + 2); + const d = view.getUint8(start + 3); + return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; +} + +function readTime(view: DataView, e: DirEntry): string { + const { start } = payload(view, e); + const parts = [view.getUint8(start), view.getUint8(start + 1), view.getUint8(start + 2)]; + return parts.map((p) => String(p).padStart(2, "0")).join(":"); +} + +const META_TAGS: { tag: string; label: string }[] = [ + { tag: "SMPL1", label: "Sample name" }, + { tag: "MCHN1", label: "Instrument" }, + { tag: "MODL1", label: "Model" }, + { tag: "TUBE1", label: "Well" }, + { tag: "LANE1", label: "Capillary" }, + { tag: "SPAC2", label: "Basecaller" }, + { tag: "SVER2", label: "Basecaller version" }, + { tag: "PDMF2", label: "Mobility file" }, + { tag: "RUND1", label: "Run started" }, + { tag: "RUND2", label: "Run finished" }, +]; + +function readMetaValue(view: DataView, e: DirEntry): string | undefined { + switch (e.elementType) { + case 2: + case 18: + case 19: + return readString(view, e); + case 4: + return String(readShorts(view, e)[0]); + case 5: + return String(view.getInt32(payload(view, e).start)); + case 10: + return readDate(view, e); + default: + return undefined; + } +} + +export function parseAb1(bytes: Uint8Array): Ab1Trace { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const dir = readDirectory(view); + const get = (...tags: string[]) => { + for (const t of tags) { + const e = dir.get(t); + if (e) return e; + } + return undefined; + }; + + const basesEntry = get("PBAS2", "PBAS1"); + if (!basesEntry) throw new Ab1ParseError("AB1 file has no base calls (PBAS tag)."); + const sequence = readChars(view, basesEntry).toUpperCase(); + + const qualEntry = get("PCON2", "PCON1"); + const quality = qualEntry ? Array.from(readBytes(view, qualEntry)) : []; + + const peaksEntry = get("PLOC2", "PLOC1"); + const peaks = peaksEntry ? Array.from(readShorts(view, peaksEntry)) : []; + + // FWO_ gives the base each DATA channel carries; DATA9..12 are the analyzed + // (processed) traces, DATA1..4 the raw ones as a fallback. + const orderEntry = get("FWO_1"); + const order = orderEntry ? readChars(view, orderEntry).toUpperCase() : "GATC"; + const empty = new Int16Array(0); + const traces: Record = { A: empty, C: empty, G: empty, T: empty }; + for (let ch = 0; ch < 4; ch++) { + const base = order[ch] as Ab1Base | undefined; + const e = get(`DATA${9 + ch}`, `DATA${1 + ch}`); + if (base && base in traces && e) traces[base] = readShorts(view, e); + } + const scanCount = Math.max(...Object.values(traces).map((t) => t.length)); + + const meta: { label: string; value: string }[] = []; + for (const { tag, label } of META_TAGS) { + const e = dir.get(tag); + if (!e) continue; + let value = readMetaValue(view, e); + if (value === undefined || value === "") continue; + // Run date and time are stored as separate tags; show them together. + if (tag === "RUND1" || tag === "RUND2") { + const time = dir.get(tag === "RUND1" ? "RUNT1" : "RUNT2"); + if (time) value = `${value} ${readTime(view, time)}`; + } + meta.push({ label, value }); + } + + return { + sequence, + quality: quality.length === sequence.length ? quality : [], + peaks: peaks.length === sequence.length ? peaks : [], + traces, + scanCount, + meta, + }; +} diff --git a/ui/src/download.ts b/ui/src/download.ts new file mode 100644 index 0000000..337429e --- /dev/null +++ b/ui/src/download.ts @@ -0,0 +1,122 @@ +// Client-side downloads: saving in-memory bytes/text, and bundling several +// small files into one stored (uncompressed) ZIP. + +export function safeName(s: string): string { + return s.replace(/[^A-Za-z0-9._-]+/g, "_"); +} + +export function downloadBlob(filename: string, blob: Blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + // Anchor must be in the DOM for click() to reliably trigger a download, and + // revoking the URL too early can cancel it — defer the revoke. + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} + +export function downloadText(filename: string, text: string) { + downloadBlob(filename, new Blob([text], { type: "text/plain" })); +} + +// --- minimal stored (uncompressed) ZIP ------------------------------------- +// Bundling the pair into one archive avoids the browser/Electron dropping the +// second of two rapid downloads. Selections are small and bounded, so "stored" +// (no compression) is fine and keeps this dependency-free. +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} + +export function zipStored(files: { name: string; data: Uint8Array }[]): Blob { + const enc = new TextEncoder(); + const u16 = (v: number) => new Uint8Array([v & 0xff, (v >>> 8) & 0xff]); + const u32 = (v: number) => + new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]); + + const local: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + + for (const f of files) { + const name = enc.encode(f.name); + const crc = crc32(f.data); + const size = f.data.length; + const lfh = concatBytes([ + u32(0x04034b50), + u16(20), + u16(0), + u16(0), + u16(0), + u16(0), + u32(crc), + u32(size), + u32(size), + u16(name.length), + u16(0), + name, + ]); + local.push(lfh, f.data); + central.push( + concatBytes([ + u32(0x02014b50), + u16(20), + u16(20), + u16(0), + u16(0), + u16(0), + u16(0), + u32(crc), + u32(size), + u32(size), + u16(name.length), + u16(0), + u16(0), + u16(0), + u16(0), + u32(0), + u32(offset), + name, + ]), + ); + offset += lfh.length + size; + } + + const centralSize = central.reduce((n, c) => n + c.length, 0); + const eocd = concatBytes([ + u32(0x06054b50), + u16(0), + u16(0), + u16(files.length), + u16(files.length), + u32(centralSize), + u32(offset), + u16(0), + ]); + const all = concatBytes([...local, ...central, eocd]); + return new Blob([all as BlobPart], { type: "application/zip" }); +} diff --git a/ui/src/pages/Ab1Viewer.vue b/ui/src/pages/Ab1Viewer.vue new file mode 100644 index 0000000..7b13d6f --- /dev/null +++ b/ui/src/pages/Ab1Viewer.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index 5916034..5ecd38e 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -1,7 +1,9 @@ + + + + diff --git a/ui/src/pages/ReadViewer.vue b/ui/src/pages/ReadViewer.vue index d23a767..34fd0a2 100644 --- a/ui/src/pages/ReadViewer.vue +++ b/ui/src/pages/ReadViewer.vue @@ -1,16 +1,11 @@