From 7affb8ab9807a53ea945fc556018e4a2f8fdcb1f Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:49:03 +0100 Subject: [PATCH 01/20] perf(live): parse each trace file once until it changes, not on every /runs.json Bead: pgp-5bja --- bin/run_state.py | 63 ++++++++++---- tests/test_run_state_trace_cache.py | 125 ++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 tests/test_run_state_trace_cache.py diff --git a/bin/run_state.py b/bin/run_state.py index a070e6d2..9e6764d6 100755 --- a/bin/run_state.py +++ b/bin/run_state.py @@ -167,27 +167,54 @@ def parse_duration(v): return total if found else None +# Parsed trace files, keyed on the path and its mtime and size: /runs.json builds every run's state, and a +# finished run's trace files do not change, so each is read once until it does. Bounded, oldest out first. +TRACE_CACHE = {} +TRACE_CACHE_MAX = 64 + + +def _trace_rows(p): + """[(process, tag, task)] of one trace file, every sample's; None when it cannot be read.""" + try: + st = os.stat(p) + except OSError: + return None + key = (st.st_mtime_ns, st.st_size) + hit = TRACE_CACHE.get(p) + if hit and hit[0] == key: + return hit[1] + try: + f = open(p, errors="replace") + except OSError: + return None + rows = [] + with f: + header = f.readline().rstrip("\n").split("\t") + for line in f: + r = dict(zip(header, line.rstrip("\n").split("\t"))) + proc, tag = task_name(r.get("name")) + if not proc or proc in SHARED_PROCESSES: + continue + start = parse_trace_time(r.get("submit")) + dur = parse_duration(r.get("duration")) + rows.append((proc, tag, {"id": r.get("task_id") or r.get("hash") or r.get("name"), "name": r.get("name"), + "process": proc, "status": (r.get("status") or "").upper(), + "exit": r.get("exit"), "start": start, + "end": start + dur if start is not None and dur is not None else None})) + TRACE_CACHE.pop(p, None) + TRACE_CACHE[p] = (key, rows) + while len(TRACE_CACHE) > TRACE_CACHE_MAX: + TRACE_CACHE.pop(next(iter(TRACE_CACHE))) + return rows + + def read_trace(paths, sample): - """Rows of the trace files tagged with the sample, as task dicts.""" + """Rows of the trace files tagged with the sample, as task dicts (each a copy: callers may change it).""" tasks = [] for p in paths: - try: - f = open(p, errors="replace") - except OSError: - continue - with f: - header = f.readline().rstrip("\n").split("\t") - for line in f: - r = dict(zip(header, line.rstrip("\n").split("\t"))) - proc, tag = task_name(r.get("name")) - if not proc or proc in SHARED_PROCESSES or not is_sample(tag, sample): - continue - start = parse_trace_time(r.get("submit")) - dur = parse_duration(r.get("duration")) - tasks.append({"id": r.get("task_id") or r.get("hash") or r.get("name"), "name": r.get("name"), - "process": proc, "status": (r.get("status") or "").upper(), - "exit": r.get("exit"), "start": start, - "end": start + dur if start is not None and dur is not None else None}) + for proc, tag, task in _trace_rows(p) or (): + if is_sample(tag, sample): + tasks.append(dict(task)) return tasks diff --git a/tests/test_run_state_trace_cache.py b/tests/test_run_state_trace_cache.py new file mode 100644 index 00000000..bc997004 --- /dev/null +++ b/tests/test_run_state_trace_cache.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""bin/run_state.py read_trace: a trace file is parsed once until it changes. + +/runs.json builds the state of every run on the server, and each build read every +trace file of every run again. read_trace now keeps the parsed rows of each file, +keyed on its path, mtime and size. Checks: + 1. the second read of an unchanged trace file opens nothing and gives the same rows; + 2. a file that grew (a task row appended) is read again and gives the new row; + 3. the rows handed out are copies: a caller that changes one does not change the cache; + 4. two samples of one trace file each get only their own rows from the one parse; + 5. the cache keeps at most TRACE_CACHE_MAX files. +Check 1 is also run against a planted mistake (a cache that keeps nothing) and must +fail there: a check that cannot fail is no check. + +Run: python3 tests/test_run_state_trace_cache.py +""" +import builtins +import os +import shutil +import sys +import tempfile + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "bin")) +import run_state # noqa: E402 + +FIX = os.path.join(REPO, "tests", "fixtures", "run_state", "trace.txt") +FAILS = [] + + +def check(desc, ok, detail=""): + print(("[PASS] " if ok else "[FAIL] ") + desc + ("" if ok else f" -- {str(detail)[:600]}")) + if not ok: + FAILS.append(desc) + + +def opens_of(path, fn): + """(result of fn(), how many times fn opened path).""" + n, real = [0], builtins.open + + def counting(file, *a, **k): + if os.path.realpath(str(file)) == os.path.realpath(path): + n[0] += 1 + return real(file, *a, **k) + builtins.open = counting + try: + out = fn() + finally: + builtins.open = real + return out, n[0] + + +def second_read_opens(trace): + run_state.TRACE_CACHE.clear() + first = run_state.read_trace([trace], "demo1") + again, n = opens_of(trace, lambda: run_state.read_trace([trace], "demo1")) + return first, again, n + + +def main(): + work = tempfile.mkdtemp(prefix="test_trace_cache.") + try: + trace = os.path.join(work, "trace.txt") + shutil.copy(FIX, trace) + + # 1. an unchanged file is not opened again + first, again, n = second_read_opens(trace) + check("1. the second read of an unchanged trace file opens nothing and gives the same rows", + n == 0 and first == again and len(first) > 0, (n, len(first), len(again))) + + # planted mistake: a cache that keeps nothing + keep = run_state.TRACE_CACHE_MAX + run_state.TRACE_CACHE_MAX = 0 + try: + _, _, n0 = second_read_opens(trace) + finally: + run_state.TRACE_CACHE_MAX = keep + check("control: a cache that keeps nothing fails check 1", n0 > 0, n0) + + # 2. a file that grew is read again + run_state.TRACE_CACHE.clear() + before = run_state.read_trace([trace], "demo1") + with open(trace, "a") as f: + f.write("99\tzz/000099\t9999\tPGX:CLINVAR_SCREEN (demo1)\tCOMPLETED\t0\t2026-01-02 11:00:00.000\t5s\t" + "4s\t50.0%\t1 MB\t1 MB\t1 MB\t1 MB\n") + after, n = opens_of(trace, lambda: run_state.read_trace([trace], "demo1")) + check("2. a trace file that grew is read again and gives the new row", + n == 1 and len(after) == len(before) + 1 and after[-1]["id"] == "99", (n, len(before), len(after))) + + # 3. copies + rows = run_state.read_trace([trace], "demo1") + rows[0]["status"] = "CHANGED BY A CALLER" + rows2 = run_state.read_trace([trace], "demo1") + check("3. a caller changing a row does not change the next read", rows2[0]["status"] != "CHANGED BY A CALLER", + rows2[0]) + + # 4. two samples from one parse + with open(trace, "a") as f: + f.write("100\tzz/000100\t10000\tPGX:CLINVAR_SCREEN (demo2)\tCOMPLETED\t0\t2026-01-02 11:00:00.000\t5s\t" + "4s\t50.0%\t1 MB\t1 MB\t1 MB\t1 MB\n") + run_state.TRACE_CACHE.clear() + one = run_state.read_trace([trace], "demo1") + two, n = opens_of(trace, lambda: run_state.read_trace([trace], "demo2")) + check("4. a second sample of the same trace file gets its own rows from the one parse", + n == 0 and [r["id"] for r in two] == ["100"] and "100" not in [r["id"] for r in one], (n, two)) + + # 5. bounded + run_state.TRACE_CACHE.clear() + for i in range(run_state.TRACE_CACHE_MAX + 5): + p = os.path.join(work, f"t{i}.txt") + shutil.copy(FIX, p) + run_state.read_trace([p], "demo1") + check("5. the cache keeps at most TRACE_CACHE_MAX files", len(run_state.TRACE_CACHE) == run_state.TRACE_CACHE_MAX, + len(run_state.TRACE_CACHE)) + finally: + shutil.rmtree(work, ignore_errors=True) + if FAILS: + print(f"\n{len(FAILS)} check(s) failed") + return 1 + print("\nAll checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d678c916506bdade26786034783dd01d979f0833 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:56:23 +0100 Subject: [PATCH 02/20] docs(live): describe the person page, the family page and the card badges, and list --pedigree with the schema Bead: pgp-5bja --- README.md | 4 ++-- docs/parameters.md | 4 ++-- docs/recheck.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cc4b123b..aa77ab96 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ This pipeline takes raw sequencing data (FASTQ/BAM/VCF) from any vendor and runs - One Nextflow DSL2 pipeline from FASTQ, BAM or VCF to the report, and every step also as a bash script you can run on its own. - Optional family analysis of two or more processed relatives (step 38, a pedigree file): a GLnexus joint call, a somalier check that the samples are the people the pedigree says, which allele a child got from each parent, de novo candidates with both parents, and compound heterozygotes in trans or in cis ([multi-sample](https://geiserx.github.io/OwnGenome/multi-sample/#family-analysis-step-38-opt-in)). - Optional couple carrier screen for two partners of the pedigree (step 39): the recessive genes where both carry a change (ClinVar hits, deletions, SMN1 copy loss, FMR1 and FXN repeats) with the chance for a child, the X-linked findings, and what the screen does not see ([step 39](https://geiserx.github.io/OwnGenome/39-couple/)). -- On the live site, a page per family: the pedigree drawn from the family file, each child's chromosome map, the relationship check, the shared segments and the couple screen ([family page](https://geiserx.github.io/OwnGenome/multi-sample/#on-the-live-site)). +- On the live site, a page per family: a pedigree where each sequenced person opens their own page, the couple screen, who is related how as a grid, each child's stretches painted by parent, the de novo and compound-het candidates, and the members' medicines genes and haplogroups side by side ([family page](https://geiserx.github.io/OwnGenome/multi-sample/#on-the-live-site)). - Ends in an HTML report and a MultiQC summary. Alternative callers (GATK, FreeBayes, Strelka2, Octopus, BWA-MEM2, TIDDIT, GRIDSS) are there for benchmarking. ## Quick start @@ -66,7 +66,7 @@ The full documentation is at **https://geiserx.github.io/OwnGenome/**, one page - [Vendor compatibility guide](https://geiserx.github.io/OwnGenome/vendor-guide/): what each provider delivers and how to get it - [Pipeline overview](https://geiserx.github.io/OwnGenome/pipeline-overview/): every step with its tool and image, which ones a default run includes, and the page for each step - [Nextflow](https://geiserx.github.io/OwnGenome/nextflow/): the workflow runner, parallel steps and resume -- [Watch a run live](https://geiserx.github.io/OwnGenome/live-site/): a step map in the browser while the run goes, behind a password. The [demo](https://geiserx.github.io/OwnGenome/demo/) replays an invented run +- [Watch a run live](https://geiserx.github.io/OwnGenome/live-site/): a results page per person, laid out by the report's chapters, a page per family, and the step map in a Progress tab while the run goes, behind a password. The [demo](https://geiserx.github.io/OwnGenome/demo/) replays an invented run - [Interpreting your results](https://geiserx.github.io/OwnGenome/interpreting-results/): what each report means and what to do with it - [Multi-sample comparison](https://geiserx.github.io/OwnGenome/multi-sample/): partners, siblings, parents - [Long-read guide](https://geiserx.github.io/OwnGenome/long-read-guide/): Nanopore and PacBio HiFi diff --git a/docs/parameters.md b/docs/parameters.md index 41231f7c..021425f7 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -1,6 +1,6 @@ # Nextflow Parameters -Every parameter of the Nextflow pipeline (`main.nf`), grouped as in [`nextflow_schema.json`](https://github.com/GeiserX/OwnGenome/blob/main/nextflow_schema.json), with its default and the description the schema gives. `--pedigree` is read by `main.nf` but is not in the schema yet; its row is written here. Give one as `-- ` to `nextflow run`, or after the sex to `run-all.sh`, which hands the options after the sex to `nextflow run` unchanged. `run-all.sh` already passes the databases it finds under `GENOME_DIR` ([Full run](getting-started.md#full-run)). How a run is built: [Nextflow Execution](nextflow.md). +Every parameter of the Nextflow pipeline (`main.nf`), grouped as in [`nextflow_schema.json`](https://github.com/GeiserX/OwnGenome/blob/main/nextflow_schema.json), with its default and the description the schema gives. Give one as `-- ` to `nextflow run`, or after the sex to `run-all.sh`, which hands the options after the sex to `nextflow run` unchanged. `run-all.sh` already passes the databases it finds under `GENOME_DIR` ([Full run](getting-started.md#full-run)). How a run is built: [Nextflow Execution](nextflow.md). A default of none means the parameter is unset; each description says what a run does without it. @@ -11,7 +11,6 @@ Define where the pipeline should find input data and save output data. | Parameter | Default | What it does | |---|---|---| | `--input` | none | Path to comma-separated samplesheet: sample, then fastq_1,fastq_2 (aligned and called), or bam,bam_index (called), or vcf,vcf_index (a VCF with one sample) with an optional bam,bam_index; sex (male or female) is required on every row that is called and when expansion_hunter is enabled. A row can also give cram,crai instead of the BAM, and gvcf,gvcf_index beside the VCF; every column: [Samplesheet Format](nextflow.md#samplesheet-format). | -| `--pedigree` | none | The family workflow (step 38, and step 39 for two samples the pedigree marks as partners): a tab-separated pedigree (sample, sex, father, mother, partner; `assets/pedigree.example.tsv`) or a families file with a leading family column (`assets/families.example.tsv`), naming samplesheet rows. Needs `--somalier_sites` and at least two of the named samples. [Family workflow](nextflow.md) and [Multi-sample comparison](multi-sample.md#family-analysis-step-38-opt-in) say what it runs. | | `--outdir` | `./results` | The output directory where results will be saved. | | `--allow_unfiltered` | `false` | Accept a VCF that has no FILTER=PASS record by treating FILTER '.' as PASS. Without it such a VCF stops the run, because every PASS-only step would report zero hits. | | `--publish_dir_mode` | `copy` | Method used to save pipeline results to output directory. One of: `symlink`, `rellink`, `link`, `copy`, `copyNoFollow`, `move`. | @@ -95,6 +94,7 @@ Paths to clinical and population genetics reference data. | `--yleaf_data` | none | Yleaf's data folder (GRCh38 marker positions and haplogroup tree), which `scripts/setup.sh --yleaf-data` installs as reference/yleaf-<version>/data: the Yleaf image ships the code only. Its ytree/ subfolder, from the same command, holds the current YFull YTree the step also places the sample on. Required with y_haplogroup. | | `--hpo` | none | The symptoms of one person as Human Phenotype Ontology ids, comma separated (HP:0001250,HP:0001263; look them up at https://hpo.jax.org). Setting it runs exomiser (step 48), which ranks the genes and variants that could explain them; it needs --exomiser_data. | | `--exomiser_data` | none | Exomiser's data folder, which `scripts/setup.sh --exomiser-data` installs as reference/exomiser-<release> (its <release>_hg38 and <release>_phenotype folders). Required with --hpo. | +| `--pedigree` | none | Opt-in: run the family workflow (steps 38 and 39) on the samples of the samplesheet this file names: a pedigree (assets/pedigree.example.tsv), or a families file with a leading family column (assets/families.example.tsv). A families file that holds more than one family needs --family_name, and only that family runs. The family that runs needs two or more of those samples, each with a gVCF, and --somalier_sites. | | `--family_name` | none | With --pedigree: the family to run when the file is a families file (a pedigree with a leading family column, assets/families.example.tsv) that holds more than one family, and the family's folder under --outdir. Without it the folder is family/. | | `--exomiser_proband` | none | The sample the --hpo terms describe. Needed when the samplesheet has more than one sample. | | `--exomiser_assembly` | `hg38` | The assembly of the Exomiser data. Keep hg38: hg19 is there for Exomiser's cut-down test release, which CI runs. One of: `hg38`, `hg19`. | diff --git a/docs/recheck.md b/docs/recheck.md index 221c6fc0..a9c8de84 100644 --- a/docs/recheck.md +++ b/docs/recheck.md @@ -91,7 +91,7 @@ python3 bin/owngenome_recheck.py --launch-dir /path/to/launch/dir ## In the reports and on the live site -The next reports read `recheck/state.json`: both show one "Database re-check" line in the run facts. A check older than the sample's `run_manifest.tsv` describes an earlier run, so the reports leave it out until the next check. When the sample has a previous run in `history/` ([the delta report](delta-report.md)), a section whose results changed since it carries a badge such as "New since 2026-10-01T09:15:00Z", in both reports, and the delta report heads its changes the same way. The live site's re-check plugin (`bin/owngenome_live_recheck.py`, `assets/live/plugins/recheck.js`) shows the same line as a banner and marks each named step on the step map with a dot whose tooltip says "newer ClinVar available" or "redone 2026-10-05". +The next reports read `recheck/state.json`: both show one "Database re-check" line in the run facts. A check older than the sample's `run_manifest.tsv` describes an earlier run, so the reports leave it out until the next check. When the sample has a previous run in `history/` ([the delta report](delta-report.md)), a section whose results changed since it carries a badge such as "New since 2026-10-01T09:15:00Z", in both reports, and the delta report heads its changes the same way. The live site's re-check plugin (`bin/owngenome_live_recheck.py`, `assets/live/plugins/recheck.js`) shows the same line as a banner, puts a badge (↻) such as "newer ClinVar available" in the header of every result card of a named step on the person page, and marks each named step on the Progress tab's step map with a dot whose tooltip says the same or "redone 2026-10-05". The Nextflow `HTML_REPORT` process sees no `history/` folder, so its report has no "New since" badge. From 79a285abc3dc115a03103970dc1e832db860b046 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:56:28 +0100 Subject: [PATCH 03/20] feat(report): mark AnnotSV class 4 and 5 SVs and MoChA calls on the genome chart The report and the live page share the chart, so both get the marks. Bead: pgp-5bja --- bin/report_charts.py | 66 ++++++++++++++++++++++++++++++++++--- tests/test_report_charts.py | 47 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/bin/report_charts.py b/bin/report_charts.py index c38a8c6f..679c0894 100755 --- a/bin/report_charts.py +++ b/bin/report_charts.py @@ -21,9 +21,12 @@ is the report's own note; a score of the other sex, or one with no percentile, says "no percentile" and is never placed. genome the chromosomes 1 to 22, X and Y of GRCh38 with the runs of - homozygosity of 5 MB or more and a tick for each ClinVar hit; + homozygosity of 5 MB or more, a tick for each ClinVar hit, an + outlined span for each AnnotSV class 4 or 5 SV and a bar under the + chromosome for each mosaic chromosomal alteration MoChA reports; drawn in the ROH section, so only when ROH ran. - Hits on chrM and on other contigs are counted in not_drawn. + Hits, SVs and calls on chrM and on other contigs are counted in + not_drawn. mito the heteroplasmic chrM sites by position and allele fraction, the D-loop poly-C length variants marked apart. @@ -31,7 +34,8 @@ not_drawn}: section is the collect_summary.SECTIONS key whose data it shows; each mark is {id, label, value, low, high, chrom, pos, end, tone, anchor, locus, detail}, with None for what does not apply; a ClinVar tick on the -genome chart also carries the ClinVar caveat, the chart's own being ROH's. anchor is the id of the +genome chart also carries the ClinVar caveat, an SV span AnnotSV's and a MoChA +bar MoChA's, the chart's own being ROH's. anchor is the id of the report element the mark links to (the ClinVar hits table, or the section); locus is "chrom:pos" or "chrom:start-end" for the genome viewer. The caveat text is passed in by the caller (notes), never written here, so the chart and the @@ -222,7 +226,40 @@ def genome_chart(S, notes): detail=f"{h.get('significance') or '.'}, {h.get('stars')} star" f"{'' if h.get('stars') == 1 else 's'}, {h.get('genotype') or '.'}" + (", low-confidence call" if low else ""))) - return spec("genome", "roh", "ideogram", "Runs of homozygosity and ClinVar hits on the chromosomes", notes, + if _ok(S, "annotsv"): + for i, r in enumerate(S["annotsv"]["data"].get("rows") or []): + m = REGION_RE.match(str(r.get("sv", ""))) + if not m or m.group(1) not in CHROM_LEN: + not_drawn["AnnotSV class 4 and 5 SVs on other contigs"] = \ + not_drawn.get("AnnotSV class 4 and 5 SVs on other contigs", 0) + 1 + continue + chrom, start, end = m.group(1), int(m.group(2)), int(m.group(3)) + genes = ", ".join((r.get("genes") or [])[:5]) or "no gene" + marks.append(mark(f"sv-{i}", f"{r.get('type') or 'SV'} {r['sv']} (AnnotSV class {r.get('acmg_class')})", + r.get("acmg_class"), chrom=chrom, pos=start, end=max(end, start), tone="attention", + anchor="structural-variants", locus=f"{chrom}:{start}-{end}", kind="sv", + caveat=notes.get("ANNOTSV_NOTE"), + detail=f"AnnotSV class {r.get('acmg_class')} {r.get('type') or 'SV'}, " + f"{r.get('length') or '.'} bp, genotype {r.get('gt') or '.'}, genes: {genes}")) + if _ok(S, "mosaic"): + for i, c in enumerate(S["mosaic"]["data"].get("calls") or []): + chrom = c.get("chrom") + try: + start, end = int(c.get("start")), int(c.get("end")) + except (TypeError, ValueError): + start = end = None + if chrom not in CHROM_LEN or start is None or end < start: + not_drawn["MoChA calls on other contigs or with no position"] = \ + not_drawn.get("MoChA calls on other contigs or with no position", 0) + 1 + continue + marks.append(mark(f"mca-{i}", f"MoChA {c.get('type') or 'call'} {chrom}:{start}-{end}", + c.get("cell_fraction"), chrom=chrom, pos=start, end=end, tone="attention", + anchor="mosaic-mca", locus=f"{chrom}:{start}-{end}", kind="mca", + caveat=notes.get("MCA_NOTE"), + detail=f"mosaic {c.get('type') or 'alteration'} over {c.get('length_mb') or '.'} Mb, " + f"cell fraction {c.get('cell_fraction') or '.'}")) + return spec("genome", "roh", "ideogram", "Runs of homozygosity, ClinVar hits, structural variants and mosaic " + "alterations on the chromosomes", notes, "ROH_NOTE", marks, axis={"assembly": "GRCh38"}, not_drawn=[{"what": k, "count": v} for k, v in not_drawn.items()]) @@ -419,6 +456,17 @@ def svg_ideogram(sp): a, b = sx(m["pos"]), sx(m["end"]) inner = (f'') + elif m.get("kind") == "sv": + # An outlined span over the chromosome, at least 5 px so a small deletion is seen. + a, b = sx(m["pos"]), sx(m["end"]) + w = max(b - a, 5) + inner = (f'') + elif m.get("kind") == "mca": + # A bar under the chromosome: a share of the cells, not the whole genome. + a, b = sx(m["pos"]), sx(m["end"]) + inner = (f'') else: x = sx(m["pos"]) inner = (f'' + _text(x0 + 348, y + 8, "low-confidence ClinVar call", 11)) + kinds = {m.get("kind") for m in sp["marks"]} + if kinds & {"sv", "mca"}: + # A second legend row, drawn only when the chart has these marks. + y += 18 + if "sv" in kinds: + legend += (f'' + _text(x0 + 20, y + 8, "AnnotSV class 4 or 5 SV", 11)) + if "mca" in kinds: + legend += (f'' + + _text(x0 + 270, y + 8, "mosaic alteration (MoChA), a share of cells", 11)) foot, h = _footer(sp, y + 26) return _open(sp, h + 4) + "".join(body) + legend + foot + "" diff --git a/tests/test_report_charts.py b/tests/test_report_charts.py index 64b82fd4..c37cb1ed 100755 --- a/tests/test_report_charts.py +++ b/tests/test_report_charts.py @@ -15,6 +15,9 @@ 4. genome: a run of homozygosity whose region cannot be read stops the chart; a run under 5 MB is not drawn; ClinVar hits on chrM and on alt contigs are counted in not_drawn; ROH_MIN_MB is collect_summary's; + each AnnotSV class 4 or 5 row is an outlined span and each reported MoChA + call a bar under its chromosome, linked to their sections, and one on + another contig is counted in not_drawn; 5. mito: one dot per heteroplasmic site, the poly-C call marked apart; 6. no caveat sentence is written in report_charts.py: they come from render_report's *_NOTE constants; @@ -119,6 +122,27 @@ def whisker_problems(svg): return out +def sv_mca_problems(sp, sv_rows, mca_calls): + """What the genome chart misses of the AnnotSV rows and MoChA calls on the primary chromosomes.""" + out = [] + marks = {m["id"]: m for m in sp["marks"]} + for i, r in enumerate(sv_rows): + if r["sv"].split(":")[0] not in report_charts.CHROM_LEN: + continue + m = marks.get(f"sv-{i}") + if not m or m["anchor"] != "structural-variants" or f'data-mark="sv-{i}"' not in sp["svg"]: + out.append(f"AnnotSV row {r['sv']} not drawn") + for i, c in enumerate(mca_calls): + m = marks.get(f"mca-{i}") + if not m or m["anchor"] != "mosaic-mca" or f'data-mark="mca-{i}"' not in sp["svg"]: + out.append(f"MoChA call {c['chrom']}:{c['start']} not drawn") + if sp["svg"].count('class="sv"') != sum(1 for r in sv_rows if r["sv"].split(":")[0] in report_charts.CHROM_LEN): + out.append("the outlined spans do not match the rows") + if sp["svg"].count('class="mca"') != len(mca_calls): + out.append("the bars do not match the calls") + return out + + def note_literals(src): """Caveat sentences written into a source file instead of passed in.""" found = [] @@ -245,6 +269,29 @@ def unit_checks(): "positions are missing", 'class="site"' not in om["svg"] and om["not_drawn"][0]["count"] == 2 and "positions of the heteroplasmic sites are not in this summary" in om["svg"] and "No heteroplasmic site" not in om["svg"], om["not_drawn"]) + sv_rows = [{"sv": "chr7:117480000-117530000", "type": "DEL", "gt": "0/1", "length": "50000", "acmg_class": "4", + "genes": ["CFTR"]}, + {"sv": "chr22_KI270879v1_alt:100-900", "type": "DUP", "gt": "0/1", "length": "800", "acmg_class": "5", + "genes": []}] + mca = [{"chrom": "chr20", "start": "30000000", "end": "42000000", "length_mb": "12.0", "type": "Del", + "cell_fraction": "0.12"}] + sm = copy.deepcopy(s) + sm["sections"]["annotsv"] = {"state": "ok", "data": {"count": 2, "rows": sv_rows}} + sm["sections"]["mosaic"] = {"state": "ok", "data": {"status": "assessed", "calls": mca}} + gm = charts(sm)["genome"] + ndm = {x["what"]: x["count"] for x in gm["not_drawn"]} + check("4. genome: each AnnotSV class 4/5 row is an outlined span, each MoChA call a bar, the alt-contig SV " + "in not_drawn, both in the legend", + sv_mca_problems(gm, sv_rows, mca) == [] and ndm.get("AnnotSV class 4 and 5 SVs on other contigs") == 1 + and "AnnotSV class 4 or 5 SV" in gm["svg"] and "mosaic alteration (MoChA)" in gm["svg"], + (sv_mca_problems(gm, sv_rows, mca), ndm)) + lost = copy.deepcopy(gm) + lost["marks"] = [m for m in lost["marks"] if m.get("kind") != "mca"] + lost["svg"] = report_charts.svg(lost) + check("4. red control: a genome chart that leaves out the MoChA call is caught", + sv_mca_problems(lost, sv_rows, mca) != []) + check("4. genome: a chart with no SV or MoChA mark draws no second legend row", + "AnnotSV class 4 or 5 SV" not in g["svg"] and "MoChA" not in g["svg"]) nr = copy.deepcopy(s) nr["sections"]["roh"]["state"] = "missing" check("4. genome: no chart when ROH did not run, even with ClinVar hits", "genome" not in charts(nr)) From 2f186fd0f8e3b994555a48a632f509ecf70806dd Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:58:04 +0100 Subject: [PATCH 04/20] feat(live): draw the repeat gauge, the Lp(a) interval, CHIP VAF, copy-number dots and the Y path in their cards Bead: pgp-5bja --- assets/live/live.css | 44 +++++++++ assets/live/person.js | 169 +++++++++++++++++++++++++++++++++ bin/live_results.py | 74 ++++++++++++--- tests/test_live_widgets.py | 187 +++++++++++++++++++++++++++++++++++++ 4 files changed, 460 insertions(+), 14 deletions(-) create mode 100644 tests/test_live_widgets.py diff --git a/assets/live/live.css b/assets/live/live.css index 08f6ce5e..5827c3d4 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -152,6 +152,50 @@ table.rows tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--ru .explore { font-size: 14px; color: var(--dim); } img.plot { max-width: 100%; height: auto; background: #fff; border-radius: 4px; margin-bottom: var(--s3); } +/* widgets (person.js): a track with bands, intervals and ticks placed by --a (start) and --w (width) */ +.widget { margin: 0 0 var(--s4); } +.w-row { margin: 0 0 var(--s3); padding-left: 8px; border-left: 3px solid transparent; } +.w-row.w-attn { border-left-color: var(--attn); } +.w-head { margin: 0 0 4px; display: flex; flex-wrap: wrap; gap: 2px 8px; align-items: baseline; } +.w-name { font-weight: 600; font-family: var(--mono); font-size: 14px; } +.w-val { font-variant-numeric: tabular-nums; } +.w-extra { color: var(--dim); font-size: 13px; } +.w-track { position: relative; height: 22px; border-radius: 4px; background: color-mix(in srgb, var(--rule) 55%, transparent); } +.w-band, .w-ci, .w-tick { position: absolute; left: var(--a); } +.w-band { top: 0; bottom: 0; width: var(--w); } +.w-band:first-child { border-radius: 4px 0 0 4px; } +.w-normal { background: color-mix(in srgb, var(--dim) 30%, transparent); } +.w-patho { background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--attn) 55%, transparent) 0 2px, transparent 2px 7px); + border-left: 1px solid var(--attn); } +.w-blind { background: repeating-linear-gradient(-45deg, color-mix(in srgb, var(--dim) 45%, transparent) 0 2px, transparent 2px 7px); + border-right: 1px dashed var(--dim); } +.w-ci { top: 50%; height: 2px; width: var(--w); margin-top: -1px; background: var(--text); } +.w-ci::before, .w-ci::after { content: ""; position: absolute; top: -5px; width: 2px; height: 12px; background: var(--text); } +.w-ci::before { left: 0; } .w-ci::after { right: 0; } +.w-tick { top: -3px; width: 4px; height: 28px; margin-left: -2px; border-radius: 2px; background: var(--text); } +.w-tick.w-low { background: var(--card); border: 2px solid var(--low); } +.w-tick.w-call { width: 12px; height: 12px; top: 5px; margin-left: -6px; border-radius: 50%; background: var(--attn); } +.w-tick:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.w-legend { margin: 4px 0 0; color: var(--dim); font-size: 13px; } +.w-key { font-size: 13px; margin: 0; max-width: 75ch; } +.w-calls { margin: 6px 0 0; padding-left: 1.1em; } +.w-fallback summary { cursor: pointer; color: var(--info); font-size: 13px; margin-top: var(--s2); } +.w-copy-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.w-copy-list li { display: grid; grid-template-columns: minmax(7ch, 12ch) auto 1fr; gap: 4px 12px; align-items: center; } +.w-copy-list .w-extra { grid-column: 2 / -1; } +.w-dots { display: inline-flex; gap: 5px; align-items: center; min-height: 14px; } +.w-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--info); } +.w-low .w-dot { background: transparent; border: 2px solid var(--low); } +ol.w-path { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 0; align-items: stretch; } +ol.w-path li { display: flex; flex-direction: column; padding: 6px 10px; border: 1px solid var(--rule); border-radius: 6px; + background: var(--card2); position: relative; margin-right: 18px; } +ol.w-path li:not(:last-child)::after { content: "\203a"; position: absolute; right: -14px; top: 50%; transform: translateY(-50%); + color: var(--dim); font-size: 18px; } +ol.w-path li.w-tip { border-color: var(--info); } +ol.w-path li.w-low { border-style: dashed; border-color: var(--low); } +.w-branch { font-family: var(--mono); font-weight: 600; } +.w-count { color: var(--dim); font-size: 12px; } + /* charts (plugins/charts.js) inside a card */ .chart-slot:empty { display: none; } figure.chart { margin: 0 0 var(--s3); } diff --git a/assets/live/person.js b/assets/live/person.js index 6433f7d1..83f4bca3 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -124,6 +124,174 @@ } U.tablist(rail, function (t) { go(t.getAttribute('href')); }); + // --- widgets: a card's numbers drawn --------------------------------------------------------------- + // HTML placed in percent of a track (custom properties, never a style attribute), so the text stays + // at its size on a phone. Every mark is focusable and says itself in words; colour is never alone. + function num(v) { var n = parseFloat(v); return isFinite(n) ? n : null; } + function pct(v, max) { return Math.max(0, Math.min(100, 100 * v / max)); } + function nice(v) { var step = v > 200 ? 50 : 10; return Math.max(step, Math.ceil(v / step) * step); } + function place(e, from, to, max) { + e.style.setProperty('--a', pct(from, max).toFixed(2) + '%'); + if (to !== undefined) { e.style.setProperty('--w', Math.max(0.6, pct(to, max) - pct(from, max)).toFixed(2) + '%'); } + return e; + } + function mark(cls, v, max, label) { + var m = el('span', { 'class': 'w-tick ' + (cls || ''), tabindex: '0', role: 'img', 'aria-label': label, title: label }); + return place(m, v, undefined, max); + } + function trackOf(max, parts) { + var t = el('div', { 'class': 'w-track' }); + parts.forEach(function (p) { if (p) { add(t, p); } }); + return t; + } + function band(cls, from, to, max, label) { + return place(el('span', { 'class': 'w-band ' + cls, title: label || '' }), from, to, max); + } + function whisker(lo, hi, max, label) { + return place(el('span', { 'class': 'w-ci', title: label || '' }), lo, hi, max); + } + function wrow(head, track, legend, cls) { + var r = el('div', { 'class': 'w-row ' + (cls || '') }); + add(r, head, track); + if (legend) { add(r, el('p', { 'class': 'w-legend' }, legend)); } + return r; + } + function whead(name, value, extra) { + var h = el('p', { 'class': 'w-head' }); + add(h, el('span', { 'class': 'w-name' }, name), ' ', el('span', { 'class': 'w-val' }, value)); + if (extra) { add(h, ' ', el('span', { 'class': 'w-extra' }, extra)); } + return h; + } + + // Repeat expansions: each allele against Stranger's thresholds, its 95% interval as a whisker. + function strGauge(w) { + var box = el('div', { 'class': 'widget w-str', 'data-widget': 'str' }); + (w.loci || []).forEach(function (l) { + var counts = String(l.repeat_count || '').split('/'); + var sizing = String(l.allele_sizing || '').split('/'); + var cis = String(l.ci || '').split('/'); + var nmax = num(l.normal_max), pmin = num(l.pathologic_min); + var top = 0; + counts.forEach(function (c, i) { + var ci = (cis[i] || '').split('-'); + top = Math.max(top, num(c) || 0, num(ci[1]) || 0); + }); + var max = nice(Math.max(top * 1.15, (pmin || 0) * 1.3, (nmax || 0) * 1.6, 10)); + var parts = []; + if (nmax !== null) { parts.push(band('w-normal', 0, nmax, max, 'Stranger’s "normal": up to ' + nmax)); } + if (pmin !== null) { parts.push(band('w-patho', pmin, max, max, 'pathologic from ' + pmin)); } + counts.forEach(function (c, i) { + var v = num(c); + if (v === null) { return; } + var ci = (cis[i] || '').split('-'); + var lo = num(ci[0]), hi = num(ci[1]); + if (lo !== null && hi !== null && hi > lo) { parts.push(whisker(lo, hi, max, '95% interval ' + lo + ' to ' + hi)); } + var unsized = sizing[i] && ['sized', 'unknown'].indexOf(sizing[i]) < 0; + parts.push(mark(unsized ? 'w-low' : '', v, max, l.locus + ' allele ' + (i + 1) + ': ' + c + ' repeats' + + (lo !== null && hi !== null ? ', 95% interval ' + lo + ' to ' + hi : '') + (unsized ? ', not sized from short reads' : ''))); + }); + var st = l.status ? 'Stranger: "' + l.status + '"' : 'no Stranger status'; + var legend = (nmax !== null || pmin !== null) + ? [nmax !== null ? '"normal" up to ' + nmax : '', pmin !== null ? 'pathologic from ' + pmin : '', 'scale 0 to ' + max] + .filter(Boolean).join(' · ') + : 'No Stranger thresholds for this locus. Scale 0 to ' + max + '.'; + var flagged = l.status && l.status.split(',').some(function (x) { return x && x !== 'normal'; }); + add(box, wrow(whead(l.locus, (l.repeat_count || '.') + ' repeats', st), trackOf(max, parts), legend, flagged ? 'w-attn' : '')); + }); + add(box, el('p', { 'class': 'w-key dim' }, 'Tick: an allele’s repeat count; line: its 95% interval; hollow: not sized ' + + 'from short reads. Shaded: Stranger’s "normal" band; hatched: its pathologic range.')); + return box; + } + + // A count with its 95% interval on a scale from 0 (Lp(a)'s KIV-2 repeats). + function ciBar(w) { + var box = el('div', { 'class': 'widget w-cibar', 'data-widget': 'ci' }); + var max = w.max || nice(w.high * 1.25); + var label = w.label + ': ' + w.value + ', 95% interval ' + w.low + ' to ' + w.high; + add(box, wrow(whead(w.label, w.value + ' ' + (w.unit || ''), '95% interval ' + w.low + ' to ' + w.high + + (w['class'] ? ' · class "' + w['class'] + '"' : '')), + trackOf(max, [whisker(w.low, w.high, max, '95% interval'), mark('', w.value, max, label)]), + 'Scale 0 to ' + max + ' ' + (w.unit || '') + '. The line is the 95% interval of the estimate.')); + return box; + } + + // CHIP: each call's VAF against the smallest VAF this depth can see. + function vafChart(w) { + var box = el('div', { 'class': 'widget w-vaf', 'data-widget': 'vaf' }); + var max = w.max || 0.5; + var limits = (w.genes || []).map(function (g) { return g.limit; }).filter(function (x) { return typeof x === 'number'; }); + var lo = limits.length ? Math.min.apply(null, limits) : null, hi = limits.length ? Math.max.apply(null, limits) : null; + var parts = []; + if (hi !== null) { parts.push(band('w-blind', 0, hi, max, 'below the detection limit at this depth')); } + (w.calls || []).forEach(function (c) { + parts.push(mark('w-call', c.vaf, max, c.gene + ' ' + (c.change || '') + ': VAF ' + c.vaf)); + }); + var head = whead('Variant allele fraction', (w.calls || []).length ? (w.calls.length + ' call' + (w.calls.length > 1 ? 's' : '')) + : 'no call above the limit'); + var legend = (hi !== null ? 'Hatched: below the smallest VAF this depth can see (' + lo + (hi !== lo ? ' to ' + hi : '') + + ' across ' + limits.length + ' genes); a clone that small is not seen. ' : '') + 'Scale VAF 0 to ' + max + '.'; + add(box, wrow(head, trackOf(max, parts), legend)); + if ((w.calls || []).length) { + var ul = el('ul', { 'class': 'w-calls' }); + w.calls.forEach(function (c) { add(ul, el('li', {}, c.gene + ' ' + (c.change || '') + ' at VAF ' + c.vaf)); }); + add(box, ul); + } + if ((w.genes || []).length) { + var d = el('details', { 'class': 'w-fallback' }); + add(d, el('summary', {}, 'Detection limit per gene')); + add(d, U.table({ columns: ['Gene', 'Mean depth', 'Smallest VAF seen', 'Status'], + rows: w.genes.map(function (g) { return [g.gene, g.depth === null ? '.' : String(g.depth), g.limit === null ? '.' : String(g.limit), g.status || '.']; }) }, 40)); + add(box, d); + } + return box; + } + + // Copy numbers as dots, one per copy; a call that is not "called" is hollow and says so. + function copyDots(w) { + var box = el('div', { 'class': 'widget w-copies', 'data-widget': 'copies' }); + var ul = el('ul', { 'class': 'w-copy-list' }); + (w.genes || []).forEach(function (g) { + var low = g.status !== 'called'; + var li = el('li', { 'class': low ? 'w-low' : '' }); + add(li, el('span', { 'class': 'w-name' }, g.gene)); + var dots = el('span', { 'class': 'w-dots', role: 'img', tabindex: '0', + 'aria-label': g.gene + ': ' + (g.copies === null ? 'no copy number' : g.copies + ' copies') + (low ? ', ' + g.status : '') }); + for (var i = 0; i < Math.min(g.copies || 0, 8); i++) { add(dots, el('span', { 'class': 'w-dot' })); } + if ((g.copies || 0) > 8) { add(dots, '+' + (g.copies - 8)); } + add(li, dots); + add(li, el('span', { 'class': 'w-val' }, g.copies === null ? 'not called' : g.copies + (g.copies === 1 ? ' copy' : ' copies'))); + if (low) { add(li, el('span', { 'class': 'w-extra' }, 'low confidence: ' + g.status)); } + add(ul, li); + }); + add(box, ul); + return box; + } + + // The Y haplogroup's path from the root of the tree to its placement. + function yPath(w) { + var box = el('div', { 'class': 'widget w-ypath', 'data-widget': 'ypath' }); + add(box, el('p', { 'class': 'w-head' }, 'From the root to ' + (w.tip || 'the placement') + (w.tree ? ' on ' + w.tree : ''))); + var ol = el('ol', { 'class': 'w-path' }); + (w.path || []).forEach(function (p, i, all) { + var seen = (p.derived || 0) + (p.ancestral || 0) + (p.no_call || 0); + var li = el('li', { 'class': (i === all.length - 1 ? 'w-tip' : '') + (p.ancestral ? ' w-low' : ''), tabindex: '0', + 'aria-label': 'Level ' + p.level + ', ' + p.branch + ': ' + p.derived + ' of ' + seen + ' markers derived' + + (p.ancestral ? ', ' + p.ancestral + ' ancestral' : '') }); + add(li, el('span', { 'class': 'w-branch' }, p.branch)); + add(li, el('span', { 'class': 'w-count' }, (p.derived === null ? '.' : p.derived) + ' of ' + seen + ' derived' + + (p.ancestral ? ' · ' + p.ancestral + ' ancestral' : ''))); + add(ol, li); + }); + add(box, ol); + return box; + } + var WIDGETS = { str: strGauge, ci: ciBar, vaf: vafChart, copies: copyDots, ypath: yPath }; + function widget(w) { + var fn = w && WIDGETS[w.kind]; + if (!fn) { return null; } + try { return fn(w); } catch (e) { return el('p', { 'class': 'dim' }, 'This drawing could not be made; the table below has the numbers.'); } + } + // --- a card ----------------------------------------------------------------------------------- function badge(text, cls, symbol) { var b = el('span', { 'class': 'chip ' + cls }); @@ -167,6 +335,7 @@ (c.notes || []).forEach(function (n) { add(art, el('p', { 'class': 'note-stale' }, n)); }); if (c.headline) { add(art, el('p', { 'class': 'headline' }, c.headline)); } add(art, U.facts(c.facts)); + add(art, widget(c.widget)); Object.keys((results && results.charts) || {}).forEach(function (chart) { if (results.charts[chart] === c.id) { add(art, el('div', { 'class': 'chart-slot', 'data-chart-slot': chart })); } }); diff --git a/bin/live_results.py b/bin/live_results.py index 74f77a53..eb80cdca 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -57,6 +57,15 @@ def cell(v): return str(v) +def num(v): + """A summary value as a float, or None ('.', '', 'NA', a range).""" + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x == x and x not in (float("inf"), float("-inf")) else None + + def table(columns, rows, limit=200): rows = [[cell(c) for c in r] for r in rows] if not rows: @@ -273,9 +282,12 @@ def a_expansions(S, s): return {"facts": facts, "attention": flagged, "table": table(["Locus", "Repeats", "Stranger status", "Normal up to", "Pathologic from", "95% interval", "Sizing", "Intermediate zone"], rows), - "widget": {"kind": "str", "loci": [{k: l.get(k) for k in ("locus", "repeat_count", "status", "normal_max", - "pathologic_min", "ci")} - for l in d.get("loci") or []]}} + # The gauge: each allele's count against Stranger's thresholds, its 95% interval as a whisker. + # status is render_report's str_status: never Stranger's "normal" for an allele not sized. + "widget": {"kind": "str", "loci": [dict({k: l.get(k) for k in ("locus", "repeat_count", "normal_max", + "pathologic_min", "ci", "allele_sizing")}, + status=rr.str_status(l)) + for l in d.get("loci") or []]}} def a_vntyper(S, s): @@ -301,17 +313,32 @@ def a_lpa(S, s): d = S["lpa"]["data"] facts = [["KIV-2 repeats", f"{d.get('kiv2_copies')} (95% interval {d.get('ci95_low')} to {d.get('ci95_high')})" if d.get("status") == "estimated" else d.get("status")], ["Class", d.get("class")]] - return {"facts": facts, "lines": rr.lpa_lines(d), "attention": d.get("flag") == "high"} + out = {"facts": facts, "lines": rr.lpa_lines(d), "attention": d.get("flag") == "high"} + v, lo, hi = num(d.get("kiv2_copies")), num(d.get("ci95_low")), num(d.get("ci95_high")) + if d.get("status") == "estimated" and v is not None and lo is not None and hi is not None: + # The count and its 95% interval on a scale from 0: the page draws the bar, the facts say it in words. + out["widget"] = {"kind": "ci", "label": "KIV-2 repeats, both chromosomes", "value": v, "low": lo, "high": hi, + "unit": "repeats", "max": max(60, 10 * int(hi * 1.25 / 10 + 1)), "class": d.get("class")} + return out def a_chip(S, s): d = S["chip"]["data"] rows = [[c.get("gene"), c.get("protein_change"), c.get("vaf"), c.get("alt_reads"), c.get("depth"), c.get("status")] for c in d.get("calls") or []] - return {"facts": [["CHIP calls", len(d.get("calls") or [])], - ["Typical detection limit at this depth (VAF)", d.get("median_limit")]], - "table": table(["Gene", "Change", "VAF", "Alt reads", "Depth", "Status"], rows), - "lines": rr.chip_lines(d, s.get("sample")), "attention": bool(d.get("calls"))} + genes = [{"gene": g.get("gene"), "limit": num(g.get("detection_limit_vaf")), "depth": num(g.get("mean_depth")), + "status": g.get("status")} for g in d.get("genes") or []] + calls = [{"gene": c.get("gene"), "change": c.get("protein_change"), "vaf": num(c.get("vaf"))} + for c in d.get("calls") or []] + out = {"facts": [["CHIP calls", len(d.get("calls") or [])], + ["Typical detection limit at this depth (VAF)", d.get("median_limit")]], + "table": table(["Gene", "Change", "VAF", "Alt reads", "Depth", "Status"], rows), + "lines": rr.chip_lines(d, s.get("sample")), "attention": bool(d.get("calls"))} + if genes or calls: + # Each call's VAF against the smallest VAF this depth can see in each gene (chip_report's own limit): + # under the limit nothing can be seen, so an empty track is not an all-clear. + out["widget"] = {"kind": "vaf", "max": 0.5, "genes": genes, "calls": [c for c in calls if c["vaf"] is not None]} + return out def a_mosaic(S, s): @@ -356,16 +383,28 @@ def a_couple(S, s): "table": table(["Result", "Gene", "Condition", "Chance for a child", "This person", "Partner"], rows)} +# The genes the copy-number dots draw (the carrier screen's copy-number genes): SMN1 and SMN2, the two +# alpha-globin genes and the RCCX module of CYP21A2. +COPY_GENES = ("SMN1", "SMN2", "HBA1", "HBA2", "CYP21A2") + + def a_paralogs(S, s): lines = [] if (S.get("parascopy") or {}).get("state") in ("ok", "stale"): d = S["parascopy"]["data"] lines += rr.parascopy_lines(d) + rr.parascopy_locus_lines(d) + rr.cn_interval_lines(d.get("cn_intervals")) - rows = [] + rows, dots = [], [] if (S.get("paralogs") or {}).get("state") in ("ok", "stale"): - rows = [[g.get("gene"), g.get("copy_number"), g.get("status"), g.get("detail")] - for g in S["paralogs"]["data"].get("genes") or []] - return {"lines": lines, "table": table(["Gene", "Copies", "Status", "Detail"], rows)} + for g in S["paralogs"]["data"].get("genes") or []: + rows.append([g.get("gene"), g.get("copy_number"), g.get("status"), g.get("detail")]) + if str(g.get("gene") or "").split(" ")[0] in COPY_GENES: + n = num(g.get("copy_number")) + dots.append({"gene": g.get("gene"), "copies": int(n) if n is not None and n == int(n) and 0 <= n <= 20 + else None, "status": g.get("status") or "."}) + out = {"lines": lines, "table": table(["Gene", "Copies", "Status", "Detail"], rows)} + if dots: + out["widget"] = {"kind": "copies", "genes": dots} + return out def a_pharmcat(S, s): @@ -446,8 +485,15 @@ def a_y(S, s): st = (S.get("y_str") or {}).get("data") or {} if st.get("markers"): facts.append(["Y-STR markers read", sum(1 for m in st["markers"] if m.get("count"))]) - return {"facts": facts, "explore": explore, - "table": table(["Level", "Branch", "Derived", "Ancestral", "No call"], rows)} + out = {"facts": facts, "explore": explore, + "table": table(["Level", "Branch", "Derived", "Ancestral", "No call"], rows)} + if t and t.get("path"): + # The path from the root of the tree to the placement, one branch per level, with its marker counts. + out["widget"] = {"kind": "ypath", "tree": t.get("tree"), "tip": t.get("haplogroup"), + "path": [{"level": p.get("Level"), "branch": p.get("Branch"), "derived": num(p.get("Derived")), + "ancestral": num(p.get("Ancestral")), "no_call": num(p.get("No_call"))} + for p in t["path"]]} + return out def a_archaic(S, s): diff --git a/tests/test_live_widgets.py b/tests/test_live_widgets.py new file mode 100644 index 00000000..987ad1a0 --- /dev/null +++ b/tests/test_live_widgets.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""The widgets of a person's page: what bin/live_results.py hands the page to draw (results.json). + +Each widget is checked on small made-up sections, against the numbers of the section it draws: + 1. repeat expansions (str): one entry per locus with its count, Stranger's thresholds and its + 95% interval; an allele not sized from short reads never carries Stranger's "normal"; + 2. Lp(a) (ci): the KIV-2 count and its 95% interval, on a scale from 0 that holds the whole + interval; no bar for a count that was not estimated; + 3. CHIP (vaf): each gene's detection limit and each call's VAF as numbers; a call with no VAF + is left off the track, not put at 0; + 4. copy numbers (copies): SMN1, SMN2, HBA1, HBA2 and the RCCX module of CYP21A2, never another + gene of the paralog table; a copy number that is not a whole number is no dots; + 5. the Y haplogroup path (ypath): one entry per level of step 37's path, root first, with its + marker counts as numbers; + 6. every widget's strings pass the wording gate (live_results.FORBIDDEN); + 7. assets/live/person.js draws each kind the adapters give (a drawer per kind). +Each check runs again on a planted mistake and must fail there: the adapter's widget dropped +(1 to 5), "ok" planted in a widget (6), a drawer removed from person.js (7). A check that +cannot fail is no check. + +Run: python3 tests/test_live_widgets.py +""" +import copy +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "bin")) +import live_results # noqa: E402 + +FAILS = [] + + +def check(desc, ok, detail=""): + print(("[PASS] " if ok else "[FAIL] ") + desc + ("" if ok else f" -- {str(detail)[:800]}")) + if not ok: + FAILS.append(desc) + + +def control(desc, still_passes): + check(f"control: {desc}", not still_passes, "the check passed on the planted mistake") + + +def sections(): + return { + "expansions": {"state": "ok", "data": {"stranger": True, "records": 2, "loci": [ + {"locus": "HTT", "repeat_count": "17/42", "status": "normal,full_mutation", "normal_max": "26", + "pathologic_min": "40", "ci": "17-17/40-45", "sizing": "sized", "allele_sizing": "sized/sized"}, + {"locus": "FMR1", "repeat_count": "30", "status": "normal", "normal_max": "44", "pathologic_min": "200", + "ci": "28-90", "sizing": "flanking_only", "allele_sizing": "flanking_only"}]}}, + "lpa": {"state": "ok", "data": {"status": "estimated", "kiv2_copies": "36.9", "ci95_low": "36.2", + "ci95_high": "37.6", "class": "typical", "flag": "."}}, + "chip": {"state": "ok", "data": {"median_limit": 0.133, "median_depth": 31.0, "calls": [ + {"gene": "DNMT3A", "protein_change": "p.R882H", "vaf": "0.081", "alt_reads": "4", "depth": "49", + "status": "PASS", "note": "invented"}, + {"gene": "TET2", "protein_change": "p.Q1034*", "vaf": ".", "alt_reads": "3", "depth": "40", "status": "PASS", "note": "invented"}], + "genes": [{"gene": "DNMT3A", "mean_depth": "31.3", "detection_limit_vaf": "0.133", "status": "covered"}, + {"gene": "TET2", "mean_depth": "29.0", "detection_limit_vaf": "0.142", "status": "covered"}]}}, + "paralogs": {"state": "ok", "data": {"genes": [ + {"gene": "SMN1", "copy_number": "2", "status": "called", "detail": "."}, + {"gene": "SMN2", "copy_number": "1", "status": "called", "detail": "."}, + {"gene": "SMN exon 7-8 deletion", "copy_number": "0", "status": "called", "detail": "."}, + {"gene": "HBA1", "copy_number": "2.4", "status": "no_call", "detail": "."}, + {"gene": "CYP21A2 (RCCX)", "copy_number": "4", "status": "called", "detail": "."}, + {"gene": "GBA1", "copy_number": "2", "status": "called", "detail": "."}]}}, + "y_tree": {"state": "ok", "data": {"haplogroup": "I-M253", "tree": "YFull YTree v14.06.0", "path": [ + {"Level": "1", "Branch": "CT", "Derived": "11", "Ancestral": "0", "No_call": "1"}, + {"Level": "2", "Branch": "F", "Derived": "9", "Ancestral": "1", "No_call": "0"}, + {"Level": "3", "Branch": "I-M253", "Derived": "7", "Ancestral": "0", "No_call": "1"}]}}, + } + + +def run(adapters, name): + S = sections() + try: + return (adapters[name](S, {"sections": S, "sample": "S"}) or {}).get("widget") + except Exception as e: # a crash is a problem, not a test error + return {"error": f"{type(e).__name__}: {e}"} + + +def str_problems(w): + if not w or w.get("kind") != "str": + return [f"no str widget: {w}"] + out = [] + by = {l["locus"]: l for l in w["loci"]} + h = by.get("HTT") or {} + if (h.get("repeat_count"), h.get("normal_max"), h.get("pathologic_min"), h.get("ci")) != ("17/42", "26", "40", "17-17/40-45"): + out.append(f"HTT {h}") + if (by.get("FMR1") or {}).get("status") == "normal": + out.append("an unsized FMR1 allele carries Stranger's normal") + return out + + +def ci_problems(w): + if not w or w.get("kind") != "ci": + return [f"no ci widget: {w}"] + out = [] + if (w["value"], w["low"], w["high"]) != (36.9, 36.2, 37.6): + out.append(f"numbers {w}") + if not w["max"] >= w["high"] or not w["low"] <= w["value"] <= w["high"]: + out.append(f"scale {w}") + return out + + +def vaf_problems(w): + if not w or w.get("kind") != "vaf": + return [f"no vaf widget: {w}"] + out = [] + if [(g["gene"], g["limit"]) for g in w["genes"]] != [("DNMT3A", 0.133), ("TET2", 0.142)]: + out.append(f"limits {w['genes']}") + if [(c["gene"], c["vaf"]) for c in w["calls"]] != [("DNMT3A", 0.081)]: + out.append(f"calls {w['calls']}") + return out + + +def copies_problems(w): + if not w or w.get("kind") != "copies": + return [f"no copies widget: {w}"] + got = [(g["gene"], g["copies"], g["status"]) for g in w["genes"]] + want = [("SMN1", 2, "called"), ("SMN2", 1, "called"), ("HBA1", None, "no_call"), ("CYP21A2 (RCCX)", 4, "called")] + return [] if got == want else [f"{got} != {want}"] + + +def ypath_problems(w): + if not w or w.get("kind") != "ypath": + return [f"no ypath widget: {w}"] + got = [(p["branch"], p["derived"], p["ancestral"], p["no_call"]) for p in w["path"]] + want = [("CT", 11, 0, 1), ("F", 9, 1, 0), ("I-M253", 7, 0, 1)] + return [] if got == want and w["tip"] == "I-M253" else [f"{got} {w.get('tip')}"] + + +CASES = [("1. repeat expansions: the gauge's loci, thresholds and intervals", "expansions", str_problems), + ("2. Lp(a): the count and its 95% interval on a scale that holds it", "lpa", ci_problems), + ("3. CHIP: the detection limits and the calls with a VAF", "chip", vaf_problems), + ("4. copy numbers: the five copy-number genes only, whole numbers as dots", "paralogs", copies_problems), + ("5. the Y path: every level, root first, with its counts", "y-chromosome", ypath_problems)] + + +def without_widget(fn): + def planted(S, s): + got = dict(fn(S, s) or {}) + got.pop("widget", None) + return got + return planted + + +def drawers(): + with open(os.path.join(REPO, "assets", "live", "person.js")) as f: + src = f.read() + m = re.search(r"var WIDGETS = \{([^}]*)\}", src) + return src, set(re.findall(r"(\w+):", m.group(1))) if m else set() + + +def main(): + A = live_results.ADAPTERS + for desc, sid, problems in CASES: + bad = problems(run(A, sid)) + check(desc, not bad, bad) + control(f"{sid} adapter without its widget", not problems(run(dict(A, **{sid: without_widget(A[sid])}), sid))) + lpa = sections()["lpa"] + lpa["data"]["status"] = "too little depth" + check("2. Lp(a): no bar for a count that was not estimated", + not (A["lpa"]({"lpa": lpa}, {"sections": {"lpa": lpa}}) or {}).get("widget")) + + widgets = {sid: run(A, sid) for _, sid, _ in CASES} + bad = live_results.wording_problems(widgets) + check("6. no widget string reads as an all-clear", not bad, bad) + planted = copy.deepcopy(widgets) + planted["lpa"]["class"] = "ok" + control("a widget saying ok", not live_results.wording_problems(planted)) + + src, kinds = drawers() + need = {w["kind"] for w in widgets.values()} + check("7. person.js has a drawer for every widget kind", need <= kinds and all(f"function {fn}" in src for fn in + ("strGauge", "ciBar", "vafChart", "copyDots", "yPath")), (need, kinds)) + control("a drawer removed from person.js", need <= (kinds - {"ypath"})) + + if FAILS: + print(f"\n{len(FAILS)} check(s) failed") + return 1 + print("\nAll checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 1b5a6b8bbf31752d1ed3b3758709726f702b9727 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:17:13 +0100 Subject: [PATCH 05/20] feat(live): draw the family page in depth on an invented trio The family page now shows the relatedness of every pair as a grid, each child's inherited stretches painted per chromosome, the de novo and compound-het tables, and the members side by side. The demo gains the partner and a son with findings of their own, so every widget and the whole family page have something to draw. Bead: pgp-5bja --- assets/live/family-page.js | 186 ++++++++++++++++-- assets/live/live.css | 38 ++++ assets/live/person.js | 2 +- bin/owngenome_live_family.py | 118 ++++++++++- tests/demo/build_live_demo.py | 113 ++++++++--- tests/demo/make_demo_sample.py | 110 +++++++++-- .../demo/DEMO-FAMILY/DEMO-003_segments.tsv | 81 ++++++++ .../live/demo/DEMO-FAMILY/compound_het.tsv | 3 + .../live/demo/DEMO-FAMILY/de_novo.tsv | 3 + .../live/demo/DEMO-FAMILY/family_plan.tsv | 4 + .../live/demo/DEMO-FAMILY/ibd_pairs.tsv | 4 + .../live/demo/DEMO-FAMILY/members.tsv | 1 + .../live/demo/DEMO-FAMILY/relationships.tsv | 4 + tests/test_live_demo.py | 120 +++++++++-- tests/test_live_family_depth.py | 164 +++++++++++++++ tests/test_render_report.py | 5 +- 16 files changed, 883 insertions(+), 73 deletions(-) create mode 100644 tests/fixtures/live/demo/DEMO-FAMILY/DEMO-003_segments.tsv create mode 100644 tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv create mode 100644 tests/fixtures/live/demo/DEMO-FAMILY/de_novo.tsv create mode 100644 tests/fixtures/live/demo/DEMO-FAMILY/family_plan.tsv create mode 100644 tests/fixtures/live/demo/DEMO-FAMILY/ibd_pairs.tsv create mode 100644 tests/fixtures/live/demo/DEMO-FAMILY/relationships.tsv create mode 100644 tests/test_live_family_depth.py diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 8a95a211..8ddcc07e 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -1,8 +1,9 @@ // family-page.js: a family's page (assets/live/family.html), from family.json // (bin/owngenome_live_family.py): the pedigree with each sequenced person a link to their page, the // members with their run progress, what steps 38 and 39 planned, each couple's carrier screen in full -// (both carry, then X-linked, then one partner), who is related how, and each child's chromosome -// map. It draws what the steps wrote and computes nothing about genetics. Text through textContent, +// (both carry, then X-linked, then one partner), who is related how as a grid (a list of pairs on a +// phone), each child's stretches painted by parent, the de novo and compound-het candidates, and the +// members side by side. It draws what the steps wrote and computes nothing about genetics. Text through textContent, // the drawing through createElementNS; images from this server only. (function () { 'use strict'; @@ -135,44 +136,207 @@ return s; } + function pairKey(a, b) { return [a, b].sort().join(' '); } + function mismatch(v) { return !!v && v !== 'matches the declared relation'; } function related(f) { var s = section('Who is related how', 'related'); var rel = f.relationships || [], ibd = f.ibd_pairs || []; if (!rel.length && !ibd.length) { add(s, el('p', { 'class': 'dim' }, 'Not run yet (step 38).')); return s; } - var byPair = {}; - ibd.forEach(function (r) { byPair[[r.sample_a, r.sample_b].sort().join(' ')] = r; }); + var byRel = {}, byIbd = {}, names = []; + function seen(n) { if (names.indexOf(n) < 0) { names.push(n); } } + rel.forEach(function (r) { byRel[pairKey(r.sample_a, r.sample_b)] = r; seen(r.sample_a); seen(r.sample_b); }); + ibd.forEach(function (r) { byIbd[pairKey(r.sample_a, r.sample_b)] = r; seen(r.sample_a); seen(r.sample_b); }); + var order = (f.state.members || []).map(function (m) { return m.sample; }); + names.sort(function (a, b) { return order.indexOf(a) - order.indexOf(b); }); + function cellText(a, b) { + var r = byRel[pairKey(a, b)] || {}, i = byIbd[pairKey(a, b)] || {}; + var lines = []; + lines.push(i.degree || (r.relatedness ? 'relatedness ' + r.relatedness : 'not measured')); + if (i.shared_cm) { lines.push(i.shared_cm + ' cM shared'); } + if (r.declared) { lines.push('declared ' + r.declared); } + return { lines: lines, flag: mismatch(r.verdict), verdict: r.verdict, reason: r.reason }; + } + // The grid: one cell per pair, the degree first, the declared relation and a flag when they differ. + var grid = el('div', { 'class': 'rel-grid-wrap' }); + var t = el('table', { 'class': 'rel-grid' }); + var head = el('tr', {}); + add(head, el('th', { scope: 'col' }, '')); + names.forEach(function (n) { add(head, el('th', { scope: 'col' }, n)); }); + add(t, add(el('thead', {}), head)); + var body = el('tbody', {}); + names.forEach(function (a) { + var tr = el('tr', {}); + add(tr, el('th', { scope: 'row' }, a)); + names.forEach(function (b) { + if (a === b) { add(tr, el('td', { 'class': 'self', 'aria-label': 'the same person' }, '·')); return; } + if (!byRel[pairKey(a, b)] && !byIbd[pairKey(a, b)]) { add(tr, el('td', { 'class': 'none' }, 'not measured')); return; } + var c = cellText(a, b); + var td = el('td', { 'class': c.flag ? 'flag' : '', title: c.reason || '' }); + c.lines.forEach(function (l, i) { add(td, el('span', { 'class': i ? 'sub' : 'main' }, l)); }); + if (c.flag) { add(td, el('span', { 'class': 'chip chip-attn' }, '⚑ ' + c.verdict)); } + add(tr, td); + }); + add(body, tr); + }); + add(t, body); + add(grid, t); + add(s, grid); + // On a phone: a list of pairs. + var ul = el('ul', { 'class': 'rel-pairs' }); + names.forEach(function (a, i) { + names.slice(i + 1).forEach(function (b) { + if (!byRel[pairKey(a, b)] && !byIbd[pairKey(a, b)]) { return; } + var c = cellText(a, b); + var li = el('li', { 'class': c.flag ? 'flag' : '' }); + add(li, el('span', { 'class': 'pair' }, a + ' and ' + b + ': '), c.lines.join(', ')); + if (c.flag) { add(li, ' ', el('span', { 'class': 'chip chip-attn' }, '⚑ ' + c.verdict + (c.reason ? ': ' + c.reason : ''))); } + add(ul, li); + }); + }); + add(s, ul); + add(s, el('p', { 'class': 'dim legend-line' }, 'Each cell: the degree the shared stretches point to (or somalier’s ' + + 'relatedness), the centimorgans shared and the declared relation. ⚑ marks a pair the measurement does not match.')); var rows = rel.map(function (r) { - var i = byPair[[r.sample_a, r.sample_b].sort().join(' ')] || {}; + var i = byIbd[pairKey(r.sample_a, r.sample_b)] || {}; return [r.sample_a + ' and ' + r.sample_b, r.declared, r.relatedness, r.ibs0, r.ibs2, i.shared_cm || '.', i.degree || '.', r.verdict, r.reason]; }); ibd.forEach(function (i) { - if (!rel.some(function (r) { return [r.sample_a, r.sample_b].sort().join(' ') === [i.sample_a, i.sample_b].sort().join(' '); })) { + if (!byRel[pairKey(i.sample_a, i.sample_b)]) { rows.push([i.sample_a + ' and ' + i.sample_b, i.declared, i.relatedness, '.', '.', i.shared_cm, i.degree, '.', '.']); } }); - add(s, U.table({ columns: ['Pair', 'Declared', 'Relatedness', 'IBS0', 'IBS2', 'Shared cM', 'Degree', 'Verdict', 'Reason'], + var d = el('details', { 'class': 'w-fallback' }); + add(d, el('summary', {}, 'Every measured number')); + add(d, U.table({ columns: ['Pair', 'Declared', 'Relatedness', 'IBS0', 'IBS2', 'Shared cM', 'Degree', 'Verdict', 'Reason'], rows: rows }, 20)); + add(s, d); if (!ibd.length) { add(s, el('p', { 'class': 'dim' }, 'Shared segments need step 26b’s panel: only the somalier half is here.')); } return s; } + // A child's stretches, painted: per chromosome the father's copy above the mother's, each stretch + // coloured by the grandparent it came from (or by the parent when no grandparent was sequenced). + var SIDE = { paternal: 'father', maternal: 'mother' }; + function painting(f, child, rows) { + var lengths = f.chrom_lengths || []; + var longest = Math.max.apply(null, lengths.map(function (x) { return x[1]; }).concat([1])); + var colours = {}, next = 1; + function colourOf(r) { + var who = r.ancestor && r.ancestor !== '.' ? r.ancestor : (r.parent && r.parent !== '.' ? r.parent : r.side + ' side'); + if (!colours[who]) { colours[who] = { n: next > 5 ? 5 : next++, side: r.side, basis: r.basis }; } + return { who: who, n: colours[who].n }; + } + var fig = el('figure', { 'class': 'painting', 'data-painting': child }); + var grid = el('div', { 'class': 'paint-grid', role: 'img', 'aria-label': 'Stretches ' + child + ' got from each parent, ' + + 'per chromosome; the table below lists them' }); + lengths.forEach(function (cl) { + var chrom = cl[0]; + add(grid, el('span', { 'class': 'paint-chr' }, chrom.replace(/^chr/, ''))); + var both = el('span', { 'class': 'paint-tracks' }); + ['paternal', 'maternal'].forEach(function (side) { + var track = el('span', { 'class': 'paint-track ' + side }); + track.style.setProperty('--len', (100 * cl[1] / longest).toFixed(2) + '%'); + rows.filter(function (r) { return r.chrom === chrom && r.side === side; }).forEach(function (r) { + var a = parseInt(r.start, 10), b = parseInt(r.end, 10); + if (!isFinite(a) || !isFinite(b) || b < a) { return; } + var c = colourOf(r); + var seg = el('span', { 'class': 'paint-seg anc' + c.n + (r.basis === 'other copy' ? ' other' : ''), + title: SIDE[side] + '’s copy, ' + chrom + ':' + a + '-' + b + ', from ' + c.who + ' (' + r.sites + ' sites, ' + + r.discordant + ' discordant)' }); + seg.style.setProperty('--a', (100 * a / cl[1]).toFixed(3) + '%'); + seg.style.setProperty('--w', Math.max(0.4, 100 * (b - a) / cl[1]).toFixed(3) + '%'); + add(track, seg); + }); + add(both, track); + }); + add(grid, both); + }); + add(fig, grid); + var leg = el('figcaption', { 'class': 'paint-legend' }); + add(leg, el('span', { 'class': 'dim' }, 'Upper line: the father’s copy; lower: the mother’s. Colour: who the stretch came from. ')); + Object.keys(colours).forEach(function (who) { + var it = el('span', { 'class': 'paint-key' }); + add(it, el('span', { 'class': 'paint-swatch anc' + colours[who].n + (colours[who].basis === 'other copy' ? ' other' : '') }), + who + (colours[who].basis === 'other copy' ? ' (the copy not from the sequenced parent)' : '')); + add(leg, it); + }); + add(leg, el('span', { 'class': 'dim' }, ' Grey: no stretch called (no informative site, a gap of the reference).')); + add(fig, leg); + var d = el('details', { 'class': 'w-fallback' }); + add(d, el('summary', {}, 'Every stretch (' + rows.length + ')')); + add(d, U.table({ columns: ['Copy', 'Chromosome', 'Start', 'End', 'From', 'Sites', 'Discordant', 'Basis'], + rows: rows.map(function (r) { return [SIDE[r.side] || r.side, r.chrom, r.start, r.end, + r.ancestor !== '.' ? r.ancestor : r.parent, r.sites, r.discordant, r.basis]; }) }, 12)); + add(fig, d); + return fig; + } + function children(f) { - var s = section('Inherited stretches', 'children'); + var s = section('Each child', 'children'); if (!f.state.children.length) { add(s, el('p', { 'class': 'dim' }, 'The pedigree names no child.')); } - f.state.children.forEach(function (c) { + var plan = {}; + (f.state.plan || []).forEach(function (r) { plan[r.analysis] = r; }); + function absent(file, analysis) { + var r = plan[analysis]; + return file + ' is not here' + (r ? ': ' + r.analysis.replace(/_/g, ' ') + ' ' + r.decision + (r.reason ? ' (' + r.reason + ')' : '') : '') + '.'; + } + f.state.children.forEach(function (c, i) { var d = el('details', { 'data-child': c.child }); + if (i === 0) { d.open = true; } add(d, el('summary', {}, c.child + ': father ' + (c.father || 'not named') + ', mother ' + (c.mother || 'not named'))); - if (c.segments) { + add(d, el('h3', {}, 'Inherited stretches')); + var rows = (f.segments || {})[c.child]; + if (rows && rows.length) { + add(d, painting(f, c.child, rows)); + } else if (c.segments) { add(d, el('img', { src: c.segments, alt: 'Chromosome map of ' + c.child, 'class': 'segments' })); } else { add(d, el('p', { 'class': 'dim' }, 'No chromosome map yet: step 38 draws one for a child with a sequenced parent.')); } + var dn = (f.de_novo || []).filter(function (r) { return r.child === c.child; }); + add(d, el('h3', {}, 'De novo candidates')); + if (dn.length) { + add(d, el('p', { 'class': 'dim' }, 'In the child and in neither parent. Most candidates in a genome are genotype errors: ' + + 'check the reads before reading anything into one.')); + add(d, U.table({ columns: ['Variant', 'Child', 'Father', 'Mother', 'Child DP / GQ', 'Allele balance', 'Quality'], + rows: dn.map(function (r) { return [r.chrom + ':' + r.pos + ' ' + r.ref + '>' + r.alt, r.child_gt, r.father_gt, + r.mother_gt, r.child_dp + ' / ' + r.child_gq, r.child_ab, r.quality]; }) }, 8)); + } else { + add(d, el('p', { 'class': 'dim' }, (f.trio_files || {})['de_novo.tsv'] ? 'None listed in de_novo.tsv for this child.' + : absent('de_novo.tsv', 'trio_phasing'))); + } + var ch = (f.compound_het || []).filter(function (r) { return r.child === c.child; }); + add(d, el('h3', {}, 'Compound-het pairs')); + if (ch.length) { + add(d, el('p', { 'class': 'dim' }, 'Two candidate variants in one gene, in trans first: one from each parent.')); + add(d, U.table({ columns: ['Gene', 'Phase', 'Variant A', 'From', 'Variant B', 'From'], + rows: ch.map(function (r) { return [r.gene, r.phase, r.variant_a, r.a_from, r.variant_b, r.b_from]; }) }, 8)); + } else { + add(d, el('p', { 'class': 'dim' }, (f.trio_files || {})['compound_het.tsv'] ? 'None listed in compound_het.tsv for this child.' + : absent('compound_het.tsv', 'trio_phasing'))); + } add(s, d); }); return s; } + // The members side by side: what each one's report says of the genes that differ most between people. + function sideBySide(f) { + var sb = f.side_by_side; + var s = section('Side by side', 'side-by-side'); + if (!sb || !sb.rows.length) { add(s, el('p', { 'class': 'dim' }, 'No member has a summary yet.')); return s; } + add(s, el('p', { 'class': 'dim' }, 'Each member’s own report, one column per person: CYP2D6, the CPIC genes ' + + '(phenotypes in CPIC’s words), HLA, the haplogroups and the blood groups.')); + var group = null, rows = []; + sb.rows.forEach(function (r) { + rows.push([r.group === group ? '' : r.group, r.label].concat(r.values)); + group = r.group; + }); + add(s, U.table({ columns: ['', 'Gene or system'].concat(sb.members), rows: rows }, 14)); + return s; + } + function more(f) { var s = section('Plan and reports', 'plan'); var plan = f.state.plan || []; @@ -205,7 +369,7 @@ add(page, top); var grid = el('div', { 'class': 'family-grid' }); add(grid, couples(f), related(f)); - add(page, grid, children(f)); + add(page, grid, children(f), sideBySide(f)); if ((f.state.plan || []).length || f.state.reports.length) { add(page, more(f)); } add(main, page); root.setAttribute('data-pgp-status', 'ready'); diff --git a/assets/live/live.css b/assets/live/live.css index 5827c3d4..9d3b4126 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -158,6 +158,7 @@ img.plot { max-width: 100%; height: auto; background: #fff; border-radius: 4px; .w-row.w-attn { border-left-color: var(--attn); } .w-head { margin: 0 0 4px; display: flex; flex-wrap: wrap; gap: 2px 8px; align-items: baseline; } .w-name { font-weight: 600; font-family: var(--mono); font-size: 14px; } +.w-label { font-weight: 600; } .w-val { font-variant-numeric: tabular-nums; } .w-extra { color: var(--dim); font-size: 13px; } .w-track { position: relative; height: 22px; border-radius: 4px; background: color-mix(in srgb, var(--rule) 55%, transparent); } @@ -324,6 +325,41 @@ img.pedigree, img.segments { max-width: 100%; height: auto; display: block; marg details[data-child] { border-top: 1px solid var(--rule); padding: var(--s2) 0; } details[data-child] summary { cursor: pointer; } +/* the relatedness grid (a list of pairs on a phone), a child's painting */ +.rel-grid-wrap { overflow-x: auto; } +table.rel-grid { border-collapse: separate; border-spacing: 4px; font-size: 13px; } +table.rel-grid th { color: var(--dim); font-weight: 600; text-align: left; padding: 2px 6px; white-space: nowrap; } +table.rel-grid td { background: var(--card2); border: 1px solid var(--rule); border-radius: 6px; padding: 6px 8px; vertical-align: top; + min-width: 11ch; } +table.rel-grid td span { display: block; } +table.rel-grid td .main { color: var(--text); font-weight: 600; } +table.rel-grid td .sub { color: var(--dim); font-size: 12px; } +table.rel-grid td.self, table.rel-grid td.none { background: transparent; border-style: dashed; color: var(--dim); text-align: center; } +table.rel-grid td.flag { border-color: var(--attn); border-left-width: 4px; } +table.rel-grid td .chip { margin-top: 4px; white-space: normal; display: inline-flex; } +ul.rel-pairs { display: none; list-style: none; margin: 0; padding: 0; } +ul.rel-pairs li { padding: 6px 0; border-bottom: 1px solid var(--rule); } +ul.rel-pairs li.flag { border-left: 4px solid var(--attn); padding-left: 8px; } +ul.rel-pairs .pair { font-weight: 600; } +ul.rel-pairs .chip { white-space: normal; } +.legend-line { font-size: 13px; margin: var(--s2) 0; } +details[data-child] h3 { font-size: 15px; margin: var(--s3) 0 var(--s2); } +.painting { margin: 0 0 var(--s3); } +.paint-grid { display: grid; grid-template-columns: 2.4em minmax(0, 1fr); gap: 3px 6px; align-items: center; } +.paint-chr { color: var(--dim); font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; } +.paint-tracks { display: grid; gap: 2px; } +.paint-track { position: relative; display: block; height: 7px; width: var(--len); border-radius: 4px; + background: color-mix(in srgb, var(--rule) 70%, transparent); overflow: hidden; } +.paint-seg { position: absolute; top: 0; bottom: 0; left: var(--a); width: var(--w); } +.anc1 { background: var(--anc1); } .anc2 { background: var(--anc2); } .anc3 { background: var(--anc3); } +.anc4 { background: var(--anc4); } .anc5 { background: var(--anc5); } +.paint-seg.other, .paint-swatch.other { background-image: repeating-linear-gradient(45deg, transparent 0 2px, rgba(0, 0, 0, .35) 2px 4px); } +.paint-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; font-size: 13px; margin-top: var(--s2); } +.paint-key { display: inline-flex; gap: 6px; align-items: center; } +.paint-swatch { display: inline-block; width: 14px; height: 10px; border-radius: 3px; } +.side-by-side table.rows td:first-child { color: var(--dim); font-size: 13px; } +.side-by-side table.rows td:nth-child(2) { font-family: var(--mono); font-size: 13px; } + /* --- login --- */ body.login main { display: flex; justify-content: center; padding-top: 12vh; } body.login form { width: 320px; display: flex; flex-direction: column; gap: 8px; } @@ -360,5 +396,7 @@ input[type=password] { font: inherit; background: var(--card); color: var(--text table.rows td::before { content: attr(data-label) ": "; color: var(--dim); font-size: 13px; } .look-list a { flex-direction: column; gap: 0; } .people { grid-template-columns: minmax(0, 1fr); } + .rel-grid-wrap { display: none; } + ul.rel-pairs { display: block; } } @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } diff --git a/assets/live/person.js b/assets/live/person.js index 83f4bca3..6a708403 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -158,7 +158,7 @@ } function whead(name, value, extra) { var h = el('p', { 'class': 'w-head' }); - add(h, el('span', { 'class': 'w-name' }, name), ' ', el('span', { 'class': 'w-val' }, value)); + add(h, el('span', { 'class': /^[A-Z0-9-]+( \(|$)/.test(name) ? 'w-name' : 'w-label' }, name), ' ', el('span', { 'class': 'w-val' }, value)); if (extra) { add(h, ' ', el('span', { 'class': 'w-extra' }, extra)); } return h; } diff --git a/bin/owngenome_live_family.py b/bin/owngenome_live_family.py index 17ec9f87..988a72e8 100644 --- a/bin/owngenome_live_family.py +++ b/bin/owngenome_live_family.py @@ -15,8 +15,13 @@ the state below, the pedigree's layout (so each person is a link), the relationship and shared- segment rows with their measured numbers, each - couple's gene rows, and each member's run progress - and attention count (from its summary.json only) + couple's gene rows, each child's inherited + stretches (_segments.tsv), the de novo and + compound-het candidates (de_novo.tsv, + compound_het.tsv), each member's run progress and + attention count, and the members side by side: + CYP2D6, the CPIC genes, HLA, the haplogroups and + the blood groups (from each summary.json only) state.json gets plugins.family: {"kind": "family", ...} on a family's page, {"kind": "member", "families": [...]} on the page of a sample in a family, @@ -182,7 +187,8 @@ def family_page(name, d): st = family_state(name, d) import render_report # the couple screen's caveat, word for word out = {"state": st, "layout": None, "relationships": [], "ibd_pairs": [], "couples": {}, - "couple_note": render_report.COUPLE_NOTE} + "couple_note": render_report.COUPLE_NOTE, "segments": {}, "de_novo": [], "compound_het": [], + "trio_files": {}, "side_by_side": None} if st.get("error"): return out order, members = members_of(d) @@ -200,6 +206,9 @@ def family_page(name, d): "partner_b", "findings_b", "explanation") rows = sorted(({k: r.get(k) for k in keep} for r in rows), key=lambda r: order_of.get(r.get("result"), 3)) out["couples"]["%s %s" % (a, b)] = rows + out.update(child_depth(d, order, members)) + import report_charts # GRCh38's lengths, for the painting of each child's stretches + out["chrom_lengths"] = [[c, n] for c, n in report_charts.GRCH38 if c != "chrY"] # Step 38 writes the verdict "ok" for a pair that matches its declared relation. The page says that # in words: no word on the page reads as an all-clear (state.json keeps the file's own value). out["state"] = dict(st, relationships=[dict(r, verdict=verdict_words(r.get("verdict"))) @@ -209,10 +218,105 @@ def family_page(name, d): return out +SEGMENT_KEYS = ("side", "parent", "chrom", "start", "end", "length_bp", "sites", "discordant", "ancestor", "basis") +DE_NOVO_KEYS = ("child", "chrom", "pos", "ref", "alt", "child_gt", "father_gt", "mother_gt", "child_dp", "child_gq", + "child_ab", "quality") +COMP_KEYS = ("child", "gene", "variant_a", "variant_b", "a_from", "b_from", "phase") +PHASE_ORDER = {"in trans": 0, "unknown": 1, "in cis": 2} + + +def child_depth(d, order, members): + """Each child's stretches (step 38's _segments.tsv) and the trio tables, as the steps wrote them: + {segments: {child: [rows]}, de_novo: [rows], compound_het: [rows, in trans first]}.""" + segs = {} + for s in order: + if not (members[s]["father"] or members[s]["mother"]): + continue + rows = read_tsv(os.path.join(d, "%s_segments.tsv" % s)) + if rows: + segs[s] = [{k: r.get(k) for k in SEGMENT_KEYS} for r in rows] + comp = [{k: r.get(k) for k in COMP_KEYS} for r in read_tsv(os.path.join(d, "compound_het.tsv"))] + comp.sort(key=lambda r: PHASE_ORDER.get(r.get("phase"), 1)) + return {"segments": segs, + "de_novo": [{k: r.get(k) for k in DE_NOVO_KEYS} for r in read_tsv(os.path.join(d, "de_novo.tsv"))], + "compound_het": comp, + "trio_files": {f: os.path.isfile(os.path.join(d, f)) for f in ("de_novo.tsv", "compound_het.tsv")}} + + +def _sec(summary, key): + sec = ((summary or {}).get("sections") or {}).get(key) or {} + return sec.get("data") or {} if sec.get("state") in ("ok", "stale") else None + + +def side_values(summary): + """[(group, label, value)] of one member's summary.json, as its report prints them; nothing computed.""" + out = [] + c = (summary or {}).get("cyp2d6") or {} + calls = ", ".join("%s %s" % (k, v) for k, v in (c.get("calls") or {}).items() if v) + if calls: + agree = c.get("agree") + out.append(("Medicines", "CYP2D6", calls + (" (callers agree)" if agree else " (callers differ)" if agree is False + else ""))) + cpic = _sec(summary, "cpic") + for g in (cpic or {}).get("genes") or []: + ph = g.get("phenotype") + out.append(("Medicines", g.get("gene"), "%s%s" % (g.get("diplotype") or ".", ' "%s"' % ph if ph else ""))) + for l in (_sec(summary, "hla") or {}).get("loci") or []: + out.append(("HLA", l.get("gene"), " ".join(l.get("alleles") or []) or "not typed")) + mt = _sec(summary, "haplogroup") + if mt is not None: + out.append(("Haplogroups", "Mitochondrial", mt.get("haplogroup") or ".")) + y = _sec(summary, "y_tree") or _sec(summary, "y_haplogroup") + if y is not None: + out.append(("Haplogroups", "Y chromosome", y.get("haplogroup") or ".")) + bt = _sec(summary, "bloodtype") + if bt is not None: + out.append(("Blood groups", "ABO", bt.get("abo") or next((x.get("phenotype") for x in bt.get("systems") or [] + if x.get("system") == "ABO"), "."))) + out.append(("Blood groups", "RhD", bt.get("rhd") or next((x.get("phenotype") for x in bt.get("systems") or [] + if x.get("system") == "RhD"), "."))) + return out + + +def side_by_side(names, summaries): + """The members' results in one table: {members, rows: [{group, label, values}]}. A member with no + summary.json yet says so in its column; a row a member's summary does not have is '.'.""" + per = {n: side_values(summaries.get(n)) for n in names} + rows, seen = [], {} + for n in names: + for g, label, _ in per[n]: + if (g, label) not in seen: + seen[(g, label)] = len(rows) + rows.append({"group": g, "label": label, "values": []}) + groups = ["Medicines", "HLA", "Haplogroups", "Blood groups"] + rows.sort(key=lambda r: (groups.index(r["group"]) if r["group"] in groups else 9, seen[(r["group"], r["label"])])) + for r in rows: + for n in names: + if summaries.get(n) is None: + r["values"].append("no summary yet") + else: + r["values"].append(next((v for g, lab, v in per[n] if (g, lab) == (r["group"], r["label"])), ".")) + return {"members": list(names), "rows": rows} + + def verdict_words(v): return "matches the declared relation" if (v or "").strip().lower() == "ok" else v +def member_summary(live, d): + """A member run's summary.json as a dict, or None (none yet, unreadable, or a link out of the run).""" + import json + p = os.path.join(d, "summary.json") if d else None + if not p or not os.path.isfile(p) or not live.inside(p, os.path.realpath(d)) or not live.inside(p, live.root): + return None + try: + with open(p) as f: + got = json.load(f) + except (OSError, ValueError): + return None + return got if isinstance(got, dict) else None + + def member_state(root, sample): fams = [] for name, d in family_dirs(root): @@ -262,6 +366,14 @@ def page_data(req, m): "planned": len(planned), "done": sum(1 for s in planned if s["state"] == "done"), "attention": n, "results": mtime is not None} out["runs"] = runs + # The members side by side, from each sequenced member's summary.json alone (read as the home + # page's attention count reads it: never a file linking out of its run). + seq = [p["sample"] for p in out["state"].get("members") or [] if p.get("sequenced")] + sums = {} + for s_ in seq: + r = live.run(s_) if live else None + sums[s_] = member_summary(live, r["dir"] or os.path.join(live.root, r["name"])) if r else None + out["side_by_side"] = side_by_side(seq, sums) if seq else None app.log("family", name, "family.json", 200) return req.send_json(out) diff --git a/tests/demo/build_live_demo.py b/tests/demo/build_live_demo.py index 1d62b912..51b2eb0b 100644 --- a/tests/demo/build_live_demo.py +++ b/tests/demo/build_live_demo.py @@ -16,8 +16,13 @@ runs/DEMO-001/... the files the plugins point at (the charts, the search index, the re-check), the report as report.html and the files it links to - runs/DEMO-FAMILY/ the family page (assets/live/family.html) of DEMO-001 and its - invented partner, with family.json as the family plugin serves it + runs/DEMO-002/, runs/DEMO-003/ the person pages of DEMO-001's invented partner and their son, + each a finished run of one frame (tests/demo/make_demo_sample.py + --sample, with the findings that show the rest of the widgets) + runs/DEMO-FAMILY/ the family page (assets/live/family.html) of the trio, with + family.json as the family plugin serves it: the invented + relationship, shared-stretch, de novo and compound-het files of + tests/fixtures/live/demo/DEMO-FAMILY and the members side by side family/DEMO-FAMILY/ the pedigree and the files the family page opens No state is written by hand. The frames are bin/owngenome_live.py's own @@ -28,8 +33,9 @@ pictures: GDIR/DEMO-001 when given (after tests/demo/retake-screenshots.sh ran its steps), else written now by tests/demo/make_demo_sample.py with N records (default 20000) and its report written by bin/render_report.py with step 24's -arguments. Steps 27, 28 and 36 run in containers and are not run here, so -without GDIR their sections stay missing in the report and on the page. +arguments. Steps 28 and 36 run in containers and are not run here, so +without GDIR their sections stay missing in the report and on the page; +step 27's CPIC table is written by make_demo_sample.py with step 27's parser. What the replay changes, and why: - step 24's files (the report, the charts, the search index, summary.json) @@ -79,6 +85,9 @@ SAMPLE = "DEMO-001" FAMILY = "DEMO-FAMILY" PARTNER = "DEMO-002" +# The family's other people: name, sex, seed. Each is a finished run with no messages (its own +# logs/run_status.tsv, closed with a finished_utc line as run-all.sh closes it). +OTHERS = (("DEMO-002", "female", 2), ("DEMO-003", "male", 3)) FIX = os.path.join(REPO, "tests", "fixtures", "live") WEBLOG = os.path.join(FIX, "demo-001.weblog.jsonl") EXTRA = os.path.join(FIX, "demo") @@ -105,16 +114,28 @@ def epoch(text): # --- the run folder ------------------------------------------------------------------------- -def report(d): +def report(d, sample=SAMPLE): """bin/render_report.py with the arguments scripts/24-html-report.sh gives it.""" subprocess.run([sys.executable, os.path.join(BIN, "render_report.py"), "--assets", os.path.join(REPO, "assets"), - "--sample", SAMPLE, "--sample-dir", d, "--json", os.path.join(d, "summary.json"), - "-o", os.path.join(d, f"{SAMPLE}_report.html"), - "--search-index", os.path.join(d, f"{SAMPLE}_search_index.json"), - "--charts-json", os.path.join(d, f"{SAMPLE}_charts.json")], + "--sample", sample, "--sample-dir", d, "--json", os.path.join(d, "summary.json"), + "-o", os.path.join(d, f"{sample}_report.html"), + "--search-index", os.path.join(d, f"{sample}_search_index.json"), + "--charts-json", os.path.join(d, f"{sample}_charts.json")], check=True, stdout=subprocess.DEVNULL) +def other_person(root, name, sex, seed, records): + """root/: an invented member written by make_demo_sample.py --sample, with its report, as a + finished run (run-all.sh's finished_utc line, 30 hours after the invented start).""" + subprocess.run([sys.executable, os.path.join(HERE, "make_demo_sample.py"), "--genome-dir", root, "--records", + str(records), "--sample", name, "--sex", sex, "--seed", str(seed)], check=True, stdout=subprocess.DEVNULL) + d = os.path.join(root, name) + import make_demo_sample + with open(os.path.join(d, "logs", "run_status.tsv"), "a") as f: + f.write(f"meta\tfinished_utc\t{time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(make_demo_sample.RUN_STARTED + 30 * 3600))}\n") + report(d, name) + + def prepare(root, genome_dir=None, records=20000): """root/DEMO-001 with its report, the family folder and the held-back files; returns the held folder.""" d = os.path.join(root, SAMPLE) @@ -137,6 +158,11 @@ def prepare(root, genome_dir=None, records=20000): for f in HELD: if os.path.isfile(os.path.join(d, f)): shutil.move(os.path.join(d, f), os.path.join(held, f)) + for name, sex, seed in OTHERS: + if genome_dir and os.path.isdir(os.path.join(genome_dir, name)): + shutil.copytree(os.path.join(genome_dir, name), os.path.join(root, name), symlinks=False) + else: + other_person(root, name, sex, seed, max(2000, records // 4)) fam = os.path.join(root, FAMILY) shutil.copytree(os.path.join(EXTRA, FAMILY), fam) couple = os.path.join(d, "couple", f"{SAMPLE}_couple_carrier.tsv") @@ -231,9 +257,20 @@ def snap(t): results = live_results.build(SAMPLE, live.summary(SAMPLE, d, live.base_state(run)), owngenome_live.Live.run_files(None, {"dir": d, "name": SAMPLE})) results["generated_utc"] = checked + # The other people: one frame each, their state at the end, and their results. + others = {} + for name, _, _ in OTHERS: + r, od = live.run(name), os.path.join(root, name) + st = live.state(r) + st["generated_utc"] = checked + st["plugins"] = relink(st["plugins"]) + res = live_results.build(name, live.summary(name, od, live.base_state(r)), + owngenome_live.Live.run_files(None, {"dir": od, "name": name})) + res["generated_utc"] = checked + others[name] = {"frames": [{"t": checked, "state": st}], "results": res} finally: time.time, run_headroom._statvfs, run_headroom.physical_memory = real - return frames, {"t": checked, "state": fam, "index": index, "results": results}, app + return frames, {"t": checked, "state": fam, "index": index, "results": results, "others": others}, app # --- the static folder ---------------------------------------------------------------------- @@ -281,6 +318,15 @@ def family_json(root, index): by = {r["name"]: r for r in index["runs"]} out["runs"] = {p["sample"]: {k: by[p["sample"]][k] for k in ("url", "state", "planned", "done", "attention", "results")} for p in out["state"].get("members") or [] if p["sample"] in by} + seq = [p["sample"] for p in out["state"].get("members") or [] if p.get("sequenced")] + sums = {} + for name in seq: + try: + with open(os.path.join(root, name, "summary.json")) as f: + sums[name] = json.load(f) + except OSError: + sums[name] = None + out["side_by_side"] = owngenome_live_family.side_by_side(seq, sums) return relink(out) @@ -297,28 +343,18 @@ def write_json(path, obj): json.dump(obj, f, separators=(",", ":")) -def write_site(out, root, frames, fam): - if os.path.exists(out): - shutil.rmtree(out) - shutil.copytree(ASSETS, os.path.join(out, "assets"), - ignore=shutil.ignore_patterns("index.html", "login.html", "home.html", "family.html")) - plugin_dir = os.path.join(ASSETS, "plugins") - plugin_js = sorted(f for f in os.listdir(plugin_dir) if owngenome_live.ASSET_RE.match(f) and f.endswith(".js")) \ - if os.path.isdir(plugin_dir) else [] - - with open(os.path.join(out, "index.html"), "w") as f: - f.write(home_page(fam["index"])) - write_json(os.path.join(out, "runs.json"), unroot(fam["index"])) - - sd, d = os.path.join(out, "runs", SAMPLE), os.path.join(root, SAMPLE) +def person_site(out, root, name, plugin_js, frames, results, autoplay=False): + """runs//: the person page, its frames, results.json, the report and the files it links to.""" + sd, d = os.path.join(out, "runs", name), os.path.join(root, name) os.makedirs(sd) with open(os.path.join(sd, "index.html"), "w") as f: - f.write(run_page(SAMPLE, plugin_js)) - write_json(os.path.join(sd, "frames.json"), {"interval_ms": INTERVAL_MS, "autoplay": True, "frames": frames}) - write_json(os.path.join(sd, "results.json"), fam["results"]) - files = {f"{SAMPLE}_report.html": "report.html"} + f.write(run_page(name, plugin_js)) + write_json(os.path.join(sd, "frames.json"), dict({"interval_ms": INTERVAL_MS, "frames": frames}, + **({"autoplay": True} if autoplay else {}))) + write_json(os.path.join(sd, "results.json"), results) + files = {f"{name}_report.html": "report.html"} # The files the report links to, as the live server lists them (Live.run_files reads no state). - files.update({rel: rel for rel in owngenome_live.Live.run_files(None, {"dir": d, "name": SAMPLE})}) + files.update({rel: rel for rel in owngenome_live.Live.run_files(None, {"dir": d, "name": name})}) for v in frames[-1]["state"]["plugins"].values(): if isinstance(v, dict) and isinstance(v.get("file"), str): files[v["file"]] = v["file"] @@ -334,6 +370,25 @@ def write_site(out, root, frames, fam): "own computer. The demo has no reads to show.

Back to the run

", "../../../assets/live.css")) + +def write_site(out, root, frames, fam): + if os.path.exists(out): + shutil.rmtree(out) + shutil.copytree(ASSETS, os.path.join(out, "assets"), + ignore=shutil.ignore_patterns("index.html", "login.html", "home.html", "family.html")) + plugin_dir = os.path.join(ASSETS, "plugins") + plugin_js = sorted(f for f in os.listdir(plugin_dir) if owngenome_live.ASSET_RE.match(f) and f.endswith(".js")) \ + if os.path.isdir(plugin_dir) else [] + + with open(os.path.join(out, "index.html"), "w") as f: + f.write(home_page(fam["index"])) + write_json(os.path.join(out, "runs.json"), unroot(fam["index"])) + + person_site(out, root, SAMPLE, plugin_js, frames, fam["results"], autoplay=True) + for name, _, _ in OTHERS: + o = fam["others"][name] + person_site(out, root, name, plugin_js, o["frames"], o["results"]) + fd = os.path.join(out, "runs", FAMILY) os.makedirs(fd) with open(os.path.join(fd, "index.html"), "w") as f: diff --git a/tests/demo/make_demo_sample.py b/tests/demo/make_demo_sample.py index 3f2492d8..a4957fe3 100755 --- a/tests/demo/make_demo_sample.py +++ b/tests/demo/make_demo_sample.py @@ -2,6 +2,7 @@ """make_demo_sample.py: write DEMO-001, the invented sample of the docs pictures. make_demo_sample.py --genome-dir DIR [--seed 1] [--records 4700000] + [--sample DEMO-001] [--sex male] make_demo_sample.py check --genome-dir DIR The first form writes DIR/DEMO-001 with every file steps 24, 27 and 28 read, @@ -36,6 +37,16 @@ Paraphase card (a PacBio HiFi step, though the invented reads are short) and the couple card, with an invented partner DEMO-002. +--sample and --sex write another invented person the same way: the live-site +demo (tests/demo/build_live_demo.py) writes DEMO-002, DEMO-001's partner +(female), and DEMO-003, their son, so the family page has a trio. A female +gets no Y files and her sex checks read two X. A few invented findings make +every live-site widget show once in the demo (FINDINGS below): DEMO-002 an +FMR1 allele in Stranger's premutation range, a CHIP call and a mosaic loss +MoChA reports, DEMO-003 an AnnotSV class 4 deletion. Every sample gets +Stranger's thresholds beside ExpansionHunter's counts and step 27's CPIC +table, written by bin/pgx_parse.py from the PharmCAT report. + The second form reads DIR/DEMO-001/summary.json (step 24 writes it) and fails when a section is not "ok", so a new report card that this script does not feed yet is caught before a picture shows "Not run". @@ -44,8 +55,10 @@ """ import argparse import calendar +import contextlib import copy import gzip +import io import json import math import os @@ -82,6 +95,15 @@ # When the invented run started: 2026-10-01 09:00 UTC, the time of the demo's # PharmCAT report too (logs/run_status.tsv, the report's "Latest run" line). RUN_STARTED = calendar.timegm((2026, 10, 1, 9, 0, 0, 0, 0, 0)) +# The invented findings of the demo's other people (--sample), one of each live-site widget's marks. +FINDINGS = {"DEMO-002": {"fmr1", "chip", "mocha"}, "DEMO-003": {"annotsv"}} +# Stranger's thresholds per locus (normal up to, pathologic from), invented for the demo's catalog. +STR_THRESHOLDS = {"HTT": (26, 40), "FMR1": (54, 200), "C9ORF72": (20, 30), "ATXN1": (35, 39), "DMPK": (34, 50), + "ATXN3": (44, 60), "AR": (34, 38), "FXN": (33, 66)} + + +def has(finding): + return finding in FINDINGS.get(SAMPLE, ()) def put(path, text, gz=False): @@ -258,6 +280,12 @@ def called(gene): changed["MT-RNR1"] = "Reference" prune_annotations(report, changed) put(os.path.join(d, "pharmcat", f"{SAMPLE}.report.json"), json.dumps(report, indent=1) + "\n") + # Step 27's tables from that report, by the parser step 27 runs (bin/pgx_parse.py). + import pgx_parse + with contextlib.redirect_stdout(io.StringIO()): + if pgx_parse.main(["cpic-report", "--sample", SAMPLE, "--report", + os.path.join(d, "pharmcat", f"{SAMPLE}.report.json"), "--outdir", os.path.join(d, "cpic")]): + raise SystemExit("pgx_parse cpic-report failed") return outside @@ -382,7 +410,8 @@ def cumulative(mean, sd, gap): put(os.path.join(i, "indexcov-indexcov.ped"), "#family_id\tsample_id\tpaternal_id\tmaternal_id\tsex\tphenotype\tCNchrX\tCNchrY\tbins.out\tbins.lo\tbins.hi\t" "bins.in\tslope\tp.out\tPC1\tPC2\tPC3\tPC4\tPC5\n" - f"unknown\t{SAMPLE}\t-9\t-9\t{1 if SEX == 'male' else 2}\t-9\t1.01\t0.98\t312\t205\t107\t174820\t0.993\t0.0018\t" + f"unknown\t{SAMPLE}\t-9\t-9\t{1 if SEX == 'male' else 2}\t-9\t{'1.01' if SEX == 'male' else '1.99'}\t" + f"{'0.98' if SEX == 'male' else '0.01'}\t312\t205\t107\t174820\t0.993\t0.0018\t" "0\t0\t0\t0\t0\n") roc = ["#chrom\tcov\t" + SAMPLE] for chrom, _ in CHROMS: @@ -408,7 +437,7 @@ def sample_qc(d): "gt_depth_sd\tdepth_mean\tdepth_sd\tab_mean\tab_std\tn_hom_ref\tn_het\tn_hom_alt\tn_unknown\tp_middling_ab\t" "X_depth_mean\tX_n\tX_hom_ref\tX_het\tX_hom_alt\tY_depth_mean\tY_n\n" f"{SAMPLE}\t{SAMPLE}\t-9\t-9\t{sex}\t-9\t-9\t31.4\t7.2\t31.4\t7.2\t0.41\t0.08\t9817\t6904\t4383\t196\t0.01\t" - "15.6\t612\t352\t4\t256\t14.9\t41\n") + + ("15.6\t612\t352\t4\t256\t14.9\t41\n" if SEX == "male" else "31.2\t612\t300\t210\t102\t0.1\t0\n")) put(os.path.join(q, "somalier", f"{SAMPLE}.pairs.tsv"), "#sample_a\tsample_b\trelatedness\tibs0\tibs2\thom_concordance\thets_a\thets_b\thets_ab\tshared_hets\t" "hom_alts_a\thom_alts_b\tshared_hom_alts\tn\tx_ibs0\tx_ibs2\texpected_relatedness\n") @@ -546,8 +575,17 @@ def write(path, recs, extra=()): # Step 05's class 4 and 5 SVs, checked (bin/annotsv_postfilter.py's # columns): none, as in most genomes. import annotsv_postfilter + cols = annotsv_postfilter.KEEP + annotsv_postfilter.ADDED + rows = [] + if has("annotsv"): + # One class 4 deletion over an invented gene, from Manta, kept by step 05's rules. + rows.append({"AnnotSV_ID": "chr16_2084000_2096000_DEL_1", "ID": "MantaDEL:1", "SV_chrom": "chr16", + "SV_start": "2084000", "SV_end": "2096000", "SV_length": "-12000", "SV_type": "DEL", + "Gene_name": "G0533", "ACMG_class": "4", "AnnotSV_ranking_score": "0.92", "Caller": "Manta", + "Exon_edge": "no", "Exon_edge_detail": ".", "Population_AF": "", "Population_source": "", + "Common": "no", "AnnotSV_class": "4", "Depth_support": ".", "Rule": "", "Rule_detail": ""}) put(os.path.join(d, "annotsv", f"{SAMPLE}_sv_class4_5_checked.tsv"), - "\t".join(annotsv_postfilter.KEEP + annotsv_postfilter.ADDED) + "\n") + "\t".join(cols) + "\n" + "".join("\t".join(r[k] for k in cols) + "\n" for r in rows)) # --- repeats, telomeres, ROH, mitochondria, Y: steps 09, 10, 11, 12, 20, 37 -------------- @@ -560,18 +598,30 @@ def write(path, recs, extra=()): def repeats_and_more(d, rng): - eh = [] + eh, stranger = [], [] for rep, c, pos, unit, lo, hi in EH_RANGES: hap = c == "chrX" and SEX == "male" a = sorted(rng.randint(lo, hi) for _ in range(1 if hap else 2)) + if rep == "FMR1" and has("fmr1") and not hap: + a = [a[0], 62] # an allele in Stranger's premutation range (above 54, under 200) ref = (lo + hi) // 2 gt = "1" if hap else ("1/2" if a[0] != a[1] else "1/1") cn = "/".join(map(str, a)) alts = ",".join(f"" for x in dict.fromkeys(a)) - eh.append(f"{c}\t{pos}\t.\t{unit[0]}\t{alts}\t.\tPASS\tEND={pos + ref * len(unit)};REF={ref};RL={ref * len(unit)};" - f"RU={unit};VARID={rep};REPID={rep}\tGT:SO:REPCN:LC\t{gt}:SPANNING:{cn}:{rng.uniform(28, 34):.6f}\n") - put(os.path.join(d, "expansion_hunter", f"{SAMPLE}_eh.vcf"), - vcf_header(['##ALT=']) + "".join(eh)) + info = f"END={pos + ref * len(unit)};REF={ref};RL={ref * len(unit)};RU={unit};VARID={rep};REPID={rep}" + lc = f"{rng.uniform(28, 34):.6f}" + eh.append(f"{c}\t{pos}\t.\t{unit[0]}\t{alts}\t.\tPASS\t{info}\tGT:SO:REPCN:LC\t{gt}:SPANNING:{cn}:{lc}\n") + # Step 9b's Stranger copy: the same record with the catalog's thresholds, the worst status of + # the alleles, and ExpansionHunter's 95% interval of each (REPCI), a few repeats wide past 50. + nmax, pmin = STR_THRESHOLDS[rep] + worst = ("full_mutation" if any(x >= pmin for x in a) else "pre_mutation" if any(x > nmax for x in a) + else "normal") + ci = "/".join(f"{x}-{x}" if x < 50 else f"{x - 4}-{x + 5}" for x in a) + stranger.append(f"{c}\t{pos}\t.\t{unit[0]}\t{alts}\t.\tPASS\t{info};STR_STATUS={worst};STR_NORMAL_MAX={nmax};" + f"STR_PATHOLOGIC_MIN={pmin}\tGT:SO:REPCN:REPCI:LC\t{gt}:SPANNING:{cn}:{ci}:{lc}\n") + head = vcf_header(['##ALT=']) + put(os.path.join(d, "expansion_hunter", f"{SAMPLE}_eh.vcf"), head + "".join(eh)) + put(os.path.join(d, "expansion_hunter", f"{SAMPLE}_eh_stranger.vcf"), head + "".join(stranger)) put(os.path.join(d, "telomere", SAMPLE, f"{SAMPLE}_summary.tsv"), "PID\tsample\ttotal_reads\tread_lengths\trepeat_threshold_set\trepeat_threshold_used\tintratelomeric_reads\t" @@ -611,6 +661,8 @@ def repeats_and_more(d, rng): put(os.path.join(d, "mito", f"{SAMPLE}_haplocheck.txt"), '"Sample"\t"Contamination Status"\t"Contamination Level"\t"Distance"\t"Sample Coverage"\n' f'"{SAMPLE}"\t"NO"\t"ND"\t"0"\t"2861"\n') + if SEX != "male": + return # no Y chromosome: step 37 has nothing to place put(os.path.join(d, "y_haplogroup", f"{SAMPLE}_y_haplogroup.txt"), "Sample_name\tHg\tHg_marker\tTotal_reads\tValid_markers\tQC-score\tQC-1\tQC-2\tQC-3\n" f"{SAMPLE}_sorted\tI-M253\tM253\t4021876\t1187\t0.982\t1.0\t0.991\t0.982\n") @@ -713,11 +765,13 @@ def mosaic(d, rng): start = (length // 4) * (k + 1) // 500 * 500 lines += [f"{chrom}\t{s}\t{s + 500}\t{max(0.0, rng.gauss(mean, mean * 0.12)):.2f}" for s in range(start, start + 200_000, 500)] - ymean = MEAN_DEPTH / 2 * rng.uniform(0.98, 1.0) - for start, end in mosaic_sex_loss.SINGLE_COPY["Y"]: + # A male's single-copy chrY at half the depth; a female's chrX (two copies) at the full depth. + sx = "Y" if SEX == "male" else "X" + ymean = MEAN_DEPTH / (2 if SEX == "male" else 1) * rng.uniform(0.98, 1.0) + for start, end in mosaic_sex_loss.SINGLE_COPY[sx][:10]: s0 = (start // 500 + 1) * 500 - lines += [f"chrY\t{s}\t{s + 500}\t{max(0.0, rng.gauss(ymean, ymean * 0.14)):.2f}" - for s in range(s0, end - 500, 500)] + lines += [f"chr{sx}\t{s}\t{s + 500}\t{max(0.0, rng.gauss(ymean, ymean * 0.14)):.2f}" + for s in range(s0, min(end, s0 + 1_000_000) - 500, 500)] put(path, "\n".join(lines) + "\n", gz=True) if mosaic_sex_loss.main(["--table", os.path.join(d, "qc", f"{SAMPLE}_sample_qc.tsv"), "--regions", path]): raise SystemExit("mosaic_sex_loss failed") @@ -813,7 +867,10 @@ def region(total, rsi, **kw): def couple(d, rng): """Step 39 with an invented partner, DEMO-002: each carries one change in a different recessive gene of the carrier list, so no gene is shared and the - card names none. Rows by bin/couple_report.py's own screen_couple.""" + card names none. Rows by bin/couple_report.py's own screen_couple. Only + DEMO-001 carries the table: the family page reads one copy of it.""" + if SAMPLE != "DEMO-001": + return import couple_report genes = couple_report.read_genes(os.path.join(REPO, "assets", "carrier_genes.tsv")) ar = sorted(g for g, v in genes.items() if v["inheritance"] == "AR" and "ACMG" in v["lists"]) @@ -988,7 +1045,15 @@ def chip(d, rng): rows.append({"gene": r["gene"], "transcript": "", "chrom": "", "coding_bases": str(rng.randint(1500, 7000)), "mean_depth": f"{mean:.1f}", "detection_limit_vaf": f"{chip_report.detection_limit(mean):.3f}", "status": "covered"}) - put(os.path.join(d, "chip", f"{SAMPLE}_chip.tsv"), "\t".join(chip_report.OUT_COLUMNS) + "\n") + calls = [] + if has("chip"): + # One small DNMT3A clone, above the detection limit at this depth (bin/chip_report.py's columns). + calls.append({"gene": "DNMT3A", "chrom": "chr2", "pos": "25234373", "ref": "C", "alt": "T", + "consequence": "missense_variant", "protein_change": "p.R882H", "exon": "23", "vaf": "0.161", + "alt_reads": "8", "depth": "50", "filter": "PASS", "status": "chip", "class": "hotspot", + "note": "a published CHIP hotspot"}) + put(os.path.join(d, "chip", f"{SAMPLE}_chip.tsv"), "\t".join(chip_report.OUT_COLUMNS) + "\n" + + "".join("\t".join(c[k] for k in chip_report.OUT_COLUMNS) + "\n" for c in calls)) put(os.path.join(d, "chip", f"{SAMPLE}_chip_depth.tsv"), "\t".join(chip_report.DEPTH_COLUMNS) + "\n" + "".join("\t".join(x[c] for c in chip_report.DEPTH_COLUMNS) + "\n" for x in rows)) @@ -1117,7 +1182,14 @@ def mocha(d): stats = [{"sample_id": SAMPLE, "call_rate": "0.9981", "cov_median": "31", "baf_auto": "0.0124", "n_hets": "2100000"}] sites = [(f"chr{i}", 10001, 10001 + 100_000_000, 120_000, 95_000, 94_000) for i in range(1, 23)] - rows, summary = mr.report(SAMPLE, [], stats, sites, "beagle") + calls = [] + if has("mocha"): + # A loss of 20q in about 12% of the blood cells, which MoChA's filters keep and the floor lets through. + calls.append({"sample_id": SAMPLE, "chrom": "chr20", "beg_GRCh38": "30100000", "end_GRCh38": "42300000", + "length": "12200000", "p_arm": "N", "q_arm": "Y", "type": "Loss", "cf": "0.120", + "bdev": "0.031", "bdev_se": "0.004", "n50_hets": "350000", "lod_baf_conc": "nan", + "rel_cov": "1.86", "lod_baf_phase": "48.2", "lod_lrr_baf": "35.1", "n_hets": "4210"}) + rows, summary = mr.report(SAMPLE, calls, stats, sites, "beagle") os.makedirs(os.path.join(d, "mosaic"), exist_ok=True) with open(os.path.join(d, "mosaic", f"{SAMPLE}_mocha.tsv"), "w", encoding="utf-8") as f: f.write("\t".join(mr.CALL_COLUMNS) + "\n") @@ -1208,6 +1280,10 @@ def run_status(d, started): # --- the two commands ---------------------------------------------------------------- def make(a): + global SAMPLE, SEX + SAMPLE, SEX = a.sample, a.sex + if not re.match(r"^DEMO-[0-9]{3}$", SAMPLE): + raise SystemExit(f"--sample {SAMPLE}: an invented name of the form DEMO-001") # A fixed start, so the report's "Latest run" line is the same on every # retake; the files written now are newer, so none is marked stale. started = RUN_STARTED @@ -1227,7 +1303,7 @@ def make(a): ("ancestry_fine", lambda r: ancestry_fine(d, r)), ("paralogs", lambda r: paralogs(d)), ("couple", lambda r: couple(d, r)), ("consumer_export", lambda r: consumer_export(d)), ("bloodtype", lambda r: bloodtype(d)), ("traits", lambda r: traits(d)), - ("y_str", lambda r: y_str(d, r)), ("viral", lambda r: viral(d)), + ("y_str", lambda r: y_str(d, r) if SEX == "male" else None), ("viral", lambda r: viral(d)), ("lpa", lambda r: lpa(d, r)), ("chip", lambda r: chip(d, r)), ("archaic", lambda r: archaic(d, r)), ("ancient", lambda r: ancient(d, r)), ("exomiser", lambda r: exomiser(d)), ("mocha", lambda r: mocha(d)), @@ -1270,6 +1346,8 @@ def main(argv=None): ap.add_argument("--genome-dir", required=True, help="an empty folder; DEMO-001 is written inside it") ap.add_argument("--seed", type=int, default=1) ap.add_argument("--records", type=int, default=4_700_000, help="variant records in the VCF (default 4700000)") + ap.add_argument("--sample", default="DEMO-001", help="the invented sample to write (default DEMO-001)") + ap.add_argument("--sex", default="male", choices=("male", "female")) return make(ap.parse_args(argv)) diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/DEMO-003_segments.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/DEMO-003_segments.tsv new file mode 100644 index 00000000..92180d5f --- /dev/null +++ b/tests/fixtures/live/demo/DEMO-FAMILY/DEMO-003_segments.tsv @@ -0,0 +1,81 @@ +child side parent chrom start end length_bp sites discordant ancestor basis +DEMO-003 paternal DEMO-001 chr1 60000 121900000 121840000 64533 5 . parent +DEMO-003 paternal DEMO-001 chr1 124900000 248896422 123996422 55803 3 . parent +DEMO-003 maternal DEMO-002 chr1 60000 121900000 121840000 55992 2 . parent +DEMO-003 maternal DEMO-002 chr1 124900000 248896422 123996422 60752 11 . parent +DEMO-003 paternal DEMO-001 chr2 60000 92400000 92340000 43618 1 . parent +DEMO-003 paternal DEMO-001 chr2 95400000 242133529 146733529 65360 11 . parent +DEMO-003 maternal DEMO-002 chr2 60000 92400000 92340000 41613 0 . parent +DEMO-003 maternal DEMO-002 chr2 95400000 242133529 146733529 70173 4 . parent +DEMO-003 paternal DEMO-001 chr3 60000 89400000 89340000 43944 5 . parent +DEMO-003 paternal DEMO-001 chr3 92400000 198235559 105835559 52576 2 . parent +DEMO-003 maternal DEMO-002 chr3 60000 89400000 89340000 40152 8 . parent +DEMO-003 maternal DEMO-002 chr3 92400000 198235559 105835559 49317 7 . parent +DEMO-003 paternal DEMO-001 chr4 60000 48500000 48440000 25738 1 . parent +DEMO-003 paternal DEMO-001 chr4 51500000 190154555 138654555 73207 5 . parent +DEMO-003 maternal DEMO-002 chr4 60000 48500000 48440000 22773 2 . parent +DEMO-003 maternal DEMO-002 chr4 51500000 190154555 138654555 62260 7 . parent +DEMO-003 paternal DEMO-001 chr5 60000 47300000 47240000 24004 3 . parent +DEMO-003 paternal DEMO-001 chr5 50300000 181478259 131178259 61441 14 . parent +DEMO-003 maternal DEMO-002 chr5 60000 47300000 47240000 23145 5 . parent +DEMO-003 maternal DEMO-002 chr5 50300000 181478259 131178259 57610 7 . parent +DEMO-003 paternal DEMO-001 chr6 60000 58300000 58240000 29222 1 . parent +DEMO-003 paternal DEMO-001 chr6 61300000 170745979 109445979 51698 12 . parent +DEMO-003 maternal DEMO-002 chr6 60000 58300000 58240000 26269 0 . parent +DEMO-003 maternal DEMO-002 chr6 61300000 170745979 109445979 51747 8 . parent +DEMO-003 paternal DEMO-001 chr7 60000 58600000 58540000 31694 4 . parent +DEMO-003 paternal DEMO-001 chr7 61600000 159285973 97685973 43109 7 . parent +DEMO-003 maternal DEMO-002 chr7 60000 58600000 58540000 27076 6 . parent +DEMO-003 maternal DEMO-002 chr7 61600000 159285973 97685973 51549 2 . parent +DEMO-003 paternal DEMO-001 chr8 60000 43700000 43640000 21668 0 . parent +DEMO-003 paternal DEMO-001 chr8 46700000 145078636 98378636 48272 9 . parent +DEMO-003 maternal DEMO-002 chr8 60000 43700000 43640000 20860 1 . parent +DEMO-003 maternal DEMO-002 chr8 46700000 145078636 98378636 49811 1 . parent +DEMO-003 paternal DEMO-001 chr9 60000 41500000 41440000 19409 4 . parent +DEMO-003 paternal DEMO-001 chr9 44500000 138334717 93834717 43043 6 . parent +DEMO-003 maternal DEMO-002 chr9 60000 41500000 41440000 18409 2 . parent +DEMO-003 maternal DEMO-002 chr9 44500000 138334717 93834717 48619 4 . parent +DEMO-003 paternal DEMO-001 chr10 60000 38300000 38240000 18949 0 . parent +DEMO-003 paternal DEMO-001 chr10 41300000 133737422 92437422 42676 1 . parent +DEMO-003 maternal DEMO-002 chr10 60000 38300000 38240000 19129 3 . parent +DEMO-003 maternal DEMO-002 chr10 41300000 133737422 92437422 42093 10 . parent +DEMO-003 paternal DEMO-001 chr11 60000 51900000 51840000 25079 5 . parent +DEMO-003 paternal DEMO-001 chr11 54900000 135026622 80126622 35266 1 . parent +DEMO-003 maternal DEMO-002 chr11 60000 51900000 51840000 24156 4 . parent +DEMO-003 maternal DEMO-002 chr11 54900000 135026622 80126622 44366 3 . parent +DEMO-003 paternal DEMO-001 chr12 60000 34000000 33940000 15308 1 . parent +DEMO-003 paternal DEMO-001 chr12 37000000 133215309 96215309 52205 11 . parent +DEMO-003 maternal DEMO-002 chr12 60000 34000000 33940000 15626 1 . parent +DEMO-003 maternal DEMO-002 chr12 37000000 133215309 96215309 42422 6 . parent +DEMO-003 paternal DEMO-001 chr13 19200000 114304328 95104328 49688 1 . parent +DEMO-003 maternal DEMO-002 chr13 19200000 114304328 95104328 52312 3 . parent +DEMO-003 paternal DEMO-001 chr14 18700000 106983718 88283718 46810 11 . parent +DEMO-003 maternal DEMO-002 chr14 18700000 106983718 88283718 46686 10 . parent +DEMO-003 paternal DEMO-001 chr15 20500000 101931189 81431189 36418 9 . parent +DEMO-003 maternal DEMO-002 chr15 20500000 101931189 81431189 41106 8 . parent +DEMO-003 paternal DEMO-001 chr16 60000 35300000 35240000 16459 4 . parent +DEMO-003 paternal DEMO-001 chr16 38300000 90278345 51978345 26172 4 . parent +DEMO-003 maternal DEMO-002 chr16 60000 35300000 35240000 17057 2 . parent +DEMO-003 maternal DEMO-002 chr16 38300000 90278345 51978345 24787 5 . parent +DEMO-003 paternal DEMO-001 chr17 60000 23600000 23540000 12779 2 . parent +DEMO-003 paternal DEMO-001 chr17 26600000 83197441 56597441 25199 5 . parent +DEMO-003 maternal DEMO-002 chr17 60000 23600000 23540000 12318 3 . parent +DEMO-003 maternal DEMO-002 chr17 26600000 83197441 56597441 26722 2 . parent +DEMO-003 paternal DEMO-001 chr18 60000 17000000 16940000 9216 0 . parent +DEMO-003 paternal DEMO-001 chr18 20000000 80313285 60313285 32940 4 . parent +DEMO-003 maternal DEMO-002 chr18 60000 17000000 16940000 7813 0 . parent +DEMO-003 maternal DEMO-002 chr18 20000000 80313285 60313285 28396 2 . parent +DEMO-003 paternal DEMO-001 chr19 60000 24700000 24640000 13479 1 . parent +DEMO-003 paternal DEMO-001 chr19 27700000 58557616 30857616 16926 0 . parent +DEMO-003 maternal DEMO-002 chr19 60000 24700000 24640000 12031 0 . parent +DEMO-003 maternal DEMO-002 chr19 27700000 58557616 30857616 15816 0 . parent +DEMO-003 paternal DEMO-001 chr20 60000 26600000 26540000 11676 2 . parent +DEMO-003 paternal DEMO-001 chr20 29600000 64384167 34784167 18751 0 . parent +DEMO-003 maternal DEMO-002 chr20 60000 26600000 26540000 12965 0 . parent +DEMO-003 maternal DEMO-002 chr20 29600000 64384167 34784167 16803 1 . parent +DEMO-003 paternal DEMO-001 chr21 13500000 46649983 33149983 17860 2 . parent +DEMO-003 maternal DEMO-002 chr21 13500000 46649983 33149983 16930 4 . parent +DEMO-003 paternal DEMO-001 chr22 16500000 50758468 34258468 18751 2 . parent +DEMO-003 maternal DEMO-002 chr22 16500000 50758468 34258468 17069 0 . parent +DEMO-003 maternal DEMO-002 chrX 2781480 59100000 56318520 24614 6 . parent +DEMO-003 maternal DEMO-002 chrX 62100000 155701382 93601382 44172 9 . parent diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv new file mode 100644 index 00000000..a5927bf3 --- /dev/null +++ b/tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv @@ -0,0 +1,3 @@ +child gene variant_a variant_b a_from b_from phase +DEMO-003 G0412 chr2:73481166:A>G chr2:73492310:C>T mother . unknown +DEMO-003 G0187 chr7:94371020:G>A chr7:94398114:T>C father mother in trans diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/de_novo.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/de_novo.tsv new file mode 100644 index 00000000..68026b85 --- /dev/null +++ b/tests/fixtures/live/demo/DEMO-FAMILY/de_novo.tsv @@ -0,0 +1,3 @@ +child chrom pos ref alt child_gt father_gt mother_gt child_dp child_gq child_ab father_dp father_gq mother_dp mother_gq quality +DEMO-003 chr3 48211904 C T 0/1 0/0 0/0 34 52 0.47 31 60 29 58 high +DEMO-003 chr11 2160877 G A 0/1 0/0 0/0 12 21 0.25 28 55 33 60 low diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/family_plan.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/family_plan.tsv new file mode 100644 index 00000000..d7db782f --- /dev/null +++ b/tests/fixtures/live/demo/DEMO-FAMILY/family_plan.tsv @@ -0,0 +1,4 @@ +family analysis members decision reason +DEMO-FAMILY joint_call DEMO-001,DEMO-002,DEMO-003 runs three members have a gVCF +DEMO-FAMILY trio_phasing DEMO-003 runs both parents of DEMO-003 are sequenced +DEMO-FAMILY couple_carrier DEMO-001,DEMO-002 runs the pedigree marks them as partners diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/ibd_pairs.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/ibd_pairs.tsv new file mode 100644 index 00000000..5b3ac9ef --- /dev/null +++ b/tests/fixtures/live/demo/DEMO-FAMILY/ibd_pairs.tsv @@ -0,0 +1,4 @@ +sample_a sample_b declared expected_relatedness segments shared_cm ibd2_cm longest_cm analysed_cm shared_fraction relatedness degree +DEMO-001 DEMO-002 partners 0 3 9.8 0.0 4.1 3480.0 0.003 0.001 distant or unrelated (short segments only) +DEMO-001 DEMO-003 parent-child 0.5 22 3471.5 0.0 283.4 3480.0 0.998 0.499 first degree (parent-child) +DEMO-002 DEMO-003 parent-child 0.5 22 3474.2 0.0 283.9 3480.0 0.998 0.499 first degree (parent-child) diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/members.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/members.tsv index f2e6afd3..2ef6963e 100644 --- a/tests/fixtures/live/demo/DEMO-FAMILY/members.tsv +++ b/tests/fixtures/live/demo/DEMO-FAMILY/members.tsv @@ -1,3 +1,4 @@ sample sex father mother partner in_pedigree sequenced DEMO-001 male DEMO-002 yes yes DEMO-002 female DEMO-001 yes yes +DEMO-003 male DEMO-001 DEMO-002 yes yes diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/relationships.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/relationships.tsv new file mode 100644 index 00000000..10f692d9 --- /dev/null +++ b/tests/fixtures/live/demo/DEMO-FAMILY/relationships.tsv @@ -0,0 +1,4 @@ +sample_a sample_b declared expected_relatedness relatedness ibs0 ibs0_rate ibs2 sites verdict reason +DEMO-001 DEMO-002 partners 0 0.012 1410 0.0957 6120 14732 ok unrelated, as declared +DEMO-001 DEMO-003 parent-child 0.5 0.497 2 0.0001 7380 14710 ok first degree, as declared +DEMO-002 DEMO-003 parent-child 0.5 0.503 1 0.0001 7451 14725 ok first degree, as declared diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 33c54dee..e3604f9b 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -24,10 +24,16 @@ page, and no policy violation; the person page's cards (the doctor tab with the ClinVar card, its caveat and its chart), the tab row moved with the arrow keys, #s/clinvar focusing its card; the family page with a - clickable pedigree and no step map or report link; and at a phone's width - (390 px) no page scrolls sideways. -Checks 1, 2, 3, 5 and the phone width also run against a planted mistake and must fail there (2: -a build with one plugin's state hook dropped): a check that cannot fail is no check. + clickable pedigree and no step map or report link; the widgets of the + partner's and the son's pages (the repeat gauge, the Lp(a) interval, the + CHIP VAF, the copy-number dots, the Y path) and the AnnotSV and MoChA marks + on their genome charts; the family page's relatedness grid, the son's + painted stretches (one mark per row of his segments file), his de novo + and compound-het tables and the members side by side; and at a phone's + width (390 px) no page scrolls sideways. +Checks 1, 2, 3, 5, the widgets (person.js without its widget line), the family depth (family.json +with the son's stretches emptied) and the phone width also run against a planted mistake and must +fail there (2: a build with one plugin's state hook dropped): a check that cannot fail is no check. Run: python3 tests/test_live_demo.py """ @@ -53,6 +59,7 @@ FAILS = [] S, FAM = build_live_demo.SAMPLE, build_live_demo.FAMILY +OTHERS = [n for n, _, _ in build_live_demo.OTHERS] def check(desc, ok, detail=""): @@ -121,7 +128,8 @@ def follow_problems(frames, steps): def links(v): if isinstance(v, dict): for k, x in v.items(): - if k in ("url", "pedigree", "segments", "report", "table") and isinstance(x, str): + # a relatedness row's "segments" is a count, not a file + if k in ("url", "pedigree", "segments", "report", "table") and isinstance(x, str) and not x.isdigit(): yield x else: yield from links(x) @@ -152,8 +160,8 @@ def link_problems(out, built): def json_link_problems(out): """Links of runs.json, results.json and family.json that are absolute or reach no file of the demo.""" bad = [] - for rel, base in (("runs.json", out), (os.path.join("runs", S, "results.json"), os.path.join(out, "runs", S)), - (os.path.join("runs", FAM, "family.json"), os.path.join(out, "runs", FAM))): + for rel, base in [("runs.json", out), (os.path.join("runs", FAM, "family.json"), os.path.join(out, "runs", FAM))] + \ + [(os.path.join("runs", n, "results.json"), os.path.join(out, "runs", n)) for n in [S] + OTHERS]: with open(os.path.join(out, rel)) as f: data = json.load(f) found = list(links(data)) @@ -205,8 +213,9 @@ def browser_checks(out): runs = b.eval("Array.from(document.querySelectorAll('.person-card a.person-link')).map(function (a) { " "return a.getAttribute('href'); }).join(' ') + ' | ' + Array.from(document.querySelectorAll(" "'.family-head a')).map(function (a) { return a.getAttribute('href'); }).join(' ')") - check("browser: the home page shows both people of the family, the sequenced one with a run linked, and the family", - people == "2" and runs == f"runs/{S}/ | runs/{FAM}/ runs/{FAM}/", (people, runs)) + want = " ".join(f"runs/{n}/" for n in [S] + OTHERS) + f" | runs/{FAM}/ runs/{FAM}/" + check("browser: the home page shows the three people of the family, each with a run linked, and the family", + people == "3" and runs == want, (people, runs)) b.go(f"{base}/runs/{S}/") first = b.wait("document.documentElement.dataset.pgpFrame && document.documentElement.dataset.pgpStatus === 'ready' " "&& document.querySelectorAll('#map g.box').length") @@ -261,6 +270,8 @@ def browser_checks(out): csp = b.eval("var m = document.querySelector('meta[http-equiv=Content-Security-Policy]'); m && m.content") check("browser: the page runs under the meta policy", csp == build_live_demo.CSP, csp) person_checks(b, base) + widget_checks(b, base, out) + family_depth_checks(b, base, out) phone_checks(b, base) finally: b.close() @@ -292,6 +303,92 @@ def person_checks(b, base): fam == f"true true ../../runs/{S}/", fam) +# The widgets: (person, tab, kind, the marks it must draw) on the demo's other people. +WIDGETS = [("DEMO-002", "clinical", "str", "[data-widget=str] .w-row", 8), + ("DEMO-002", "clinical", "ci", "[data-widget=ci] .w-tick", 1), + ("DEMO-002", "clinical", "vaf", "[data-widget=vaf] .w-tick.w-call", 1), + ("DEMO-003", "carrier", "copies", "[data-widget=copies] li", 5), + ("DEMO-003", "ancestry", "ypath", "[data-widget=ypath] li", 5), + ("DEMO-002", "clinical", "MoChA bar", "figure[data-chart=genome] rect.mca", 1), + ("DEMO-003", "clinical", "AnnotSV span", "figure[data-chart=genome] rect.sv", 1)] + + +def widget_counts(b, base): + got = {} + for person, tab, kind, sel, _ in WIDGETS: + b.go("about:blank") # a new document each time, so the wait never reads the page before + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{person}/#q/{tab}") + b.wait("document.documentElement.dataset.pgpResults && document.documentElement.dataset.pgpTab === %s" + % json.dumps(tab)) + if kind in ("MoChA bar", "AnnotSV span"): + b.wait("document.documentElement.dataset.pgpCharts") + time.sleep(0.5) + got[kind] = b.eval("document.querySelectorAll(%s).length" % json.dumps(sel)) + return got + + +def widget_problems(got): + return [f"{kind}: {got.get(kind)} marks, want {n}" for _, _, kind, _, n in WIDGETS if got.get(kind) != n] + + +def widget_checks(b, base, out): + """Every widget kind drawn with its marks on the partner's and the son's pages.""" + b.call("Network.enable", session=b.session) # the control below changes person.js: never serve it from cache + b.call("Network.setCacheDisabled", {"cacheDisabled": True}, b.session) + got = widget_counts(b, base) + bad = widget_problems(got) + check("browser: every widget and genome-chart mark is drawn with its marks (" + ", ".join(k for _, _, k, _, _ in WIDGETS) + + ")", not bad, bad) + js = os.path.join(out, "assets", "person.js") + with open(js) as f: + keep = f.read() + try: + with open(js, "w") as f: + f.write(keep.replace("add(art, widget(c.widget));", "")) + control("person.js without its widget line", not widget_problems(widget_counts(b, base))) + finally: + with open(js, "w") as f: + f.write(keep) + + +def family_depth(b, base): + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{FAM}/") + return b.wait("document.documentElement.dataset.pgpStatus === 'ready' && JSON.stringify([" + "document.querySelectorAll('table.rel-grid td:not(.self):not(.none)').length, " + "document.querySelectorAll('[data-painting=\"DEMO-003\"] .paint-seg').length, " + "document.querySelectorAll('details[data-child=\"DEMO-003\"] table.rows').length, " + "document.querySelectorAll('section.side-by-side table.rows thead th').length])") + + +def family_depth_problems(text, rows): + got = json.loads(text) if text else [0, 0, 0, 0] + want = [6, rows, 3, 2 + 3] # 3 pairs both ways; the stretches; painting, de novo, pairs; label columns + 3 people + return [] if got == want else [f"{got} != {want}"] + + +def family_depth_checks(b, base, out): + with open(os.path.join(build_live_demo.EXTRA, FAM, "DEMO-003_segments.tsv")) as f: + rows = sum(1 for line in f if line.strip()) - 1 + bad = family_depth_problems(family_depth(b, base), rows) + check("browser: the family page draws the relatedness grid, the son's stretches row for row, his de novo and " + "compound-het tables and the members side by side", not bad, bad) + fj = os.path.join(out, "runs", FAM, "family.json") + with open(fj) as f: + keep = f.read() + try: + planted = json.loads(keep) + planted["segments"] = {} + with open(fj, "w") as f: + json.dump(planted, f) + control("family.json with the son's stretches emptied", not family_depth_problems(family_depth(b, base), rows)) + finally: + with open(fj, "w") as f: + f.write(keep) + + def too_wide(b): return b.eval("document.documentElement.scrollWidth > window.innerWidth ? " "document.documentElement.scrollWidth + ' > ' + window.innerWidth : ''") @@ -305,7 +402,8 @@ def phone_checks(b, base): wide = {} for name, path, ready in (("home", "/", "document.documentElement.dataset.pgpStatus === 'ready'"), ("person", f"/runs/{S}/#q/clinical", "document.documentElement.dataset.pgpResults"), - ("family", f"/runs/{FAM}/", "document.documentElement.dataset.pgpStatus === 'ready'")): + ("family", f"/runs/{FAM}/", "document.documentElement.dataset.pgpStatus === 'ready'"), + ("partner", f"/runs/{OTHERS[0]}/#q/clinical", "document.documentElement.dataset.pgpResults")): b.go(base + path) b.wait(ready) if name == "person": @@ -398,7 +496,7 @@ def main(): for p, _, fs in os.walk(out): for f in fs: rel = os.path.relpath(os.path.join(p, f), out) - if f.endswith((".html", ".js", ".css")) and rel != os.path.join("runs", S, "report.html"): + if f.endswith((".html", ".js", ".css")) and rel not in [os.path.join("runs", n, "report.html") for n in [S] + OTHERS]: with open(os.path.join(p, f)) as fh: texts[rel] = fh.read() if f.endswith(".html"): diff --git a/tests/test_live_family_depth.py b/tests/test_live_family_depth.py new file mode 100644 index 00000000..d3395234 --- /dev/null +++ b/tests/test_live_family_depth.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""The family page's depth (bin/owngenome_live_family.py family.json), on the demo's invented trio +(tests/fixtures/live/demo/DEMO-FAMILY) and on small made-up summaries. + +Checks: + 1. each child's stretches are its _segments.tsv row for row, and the chromosome lengths + the painting scales by are GRCh38's chr1 to chr22 and chrX; + 2. the compound-het pairs come in trans first, and every de novo row is there; + 3. a family without the trio files says they are not there (trio_files) and lists nothing; + 4. the members side by side: one row per gene or system any member's summary.json has, one + column per member, '.' where a member's summary lacks the row, "no summary yet" for a member + without one; CPIC's phenotype quoted, never bare; + 5. a member's summary.json that links out of its run folder is never read; + 6. family.json passes the wording gate (live_results.FORBIDDEN). +Each check runs again on a planted mistake and must fail there: the segments read from the wrong +child (1), the pairs left in file order (2), a trio file planted (3), a member's summary dropped (4), +the inside check bypassed (5), "ok" planted in a row (6). A check that cannot fail is no check. + +Run: python3 tests/test_live_family_depth.py +""" +import copy +import json +import os +import shutil +import sys +import tempfile + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "bin")) +import live_results # noqa: E402 +import owngenome_live # noqa: E402 +import owngenome_live_family as fam # noqa: E402 + +DEMO = os.path.join(REPO, "tests", "fixtures", "live", "demo", "DEMO-FAMILY") +FAILS = [] + + +def check(desc, ok, detail=""): + print(("[PASS] " if ok else "[FAIL] ") + desc + ("" if ok else f" -- {str(detail)[:800]}")) + if not ok: + FAILS.append(desc) + + +def control(desc, still_passes): + check(f"control: {desc}", not still_passes, "the check passed on the planted mistake") + + +def segment_problems(page, d): + rows = fam.read_tsv(os.path.join(d, "DEMO-003_segments.tsv")) + got = page["segments"].get("DEMO-003") or [] + out = [] + if [(r["side"], r["chrom"], r["start"], r["end"]) for r in got] != \ + [(r["side"], r["chrom"], r["start"], r["end"]) for r in rows] or not rows: + out.append(f"{len(got)} stretches against {len(rows)} rows") + if [c for c, _ in page["chrom_lengths"]] != [f"chr{i}" for i in range(1, 23)] + ["chrX"]: + out.append(f"lengths {page['chrom_lengths'][:3]}") + return out + + +def pair_problems(page): + phases = [r["phase"] for r in page["compound_het"]] + out = [] + if not phases or phases[0] != "in trans" or phases != sorted(phases, key=lambda p: fam.PHASE_ORDER.get(p, 1)): + out.append(f"order {phases}") + if len(page["de_novo"]) != 2: + out.append(f"{len(page['de_novo'])} de novo rows") + return out + + +def summary(cyp, cpic, hap, y=None): + S = {"cpic": {"state": "ok", "data": {"genes": [{"gene": g, "diplotype": dip, "phenotype": ph} for g, dip, ph in cpic]}}, + "haplogroup": {"state": "ok", "data": {"haplogroup": hap}}, + "bloodtype": {"state": "ok", "data": {"abo": "A1", "rhd": "D+"}}} + if y: + S["y_tree"] = {"state": "ok", "data": {"haplogroup": y}} + return {"cyp2d6": {"calls": {"pypgx": cyp, "Cyrius": cyp}, "agree": True}, "sections": S} + + +def side_problems(sb): + by = {(r["group"], r["label"]): r["values"] for r in sb["rows"]} + want = {("Medicines", "CYP2D6"): ["pypgx *1/*4, Cyrius *1/*4 (callers agree)", "pypgx *1/*1, Cyrius *1/*1 (callers agree)", + "no summary yet"], + ("Medicines", "CYP2C19"): ['*1/*2 "Intermediate Metabolizer"', ".", "no summary yet"], + ("Medicines", "DPYD"): ['Reference/Reference "Normal Metabolizer"'] * 2 + ["no summary yet"], + ("Haplogroups", "Y chromosome"): ["I-M253", ".", "no summary yet"], + ("Blood groups", "ABO"): ["A1", "A1", "no summary yet"]} + out = [f"{k}: {by.get(k)} != {v}" for k, v in want.items() if by.get(k) != v] + if sb["members"] != ["A", "B", "C"]: + out.append(f"members {sb['members']}") + groups = [r["group"] for r in sb["rows"]] + if groups != sorted(groups, key=["Medicines", "HLA", "Haplogroups", "Blood groups"].index): + out.append(f"groups out of order {groups}") + return out + + +def main(): + work = tempfile.mkdtemp(prefix="test_live_family_depth.") + try: + page = fam.family_page("DEMO-FAMILY", DEMO) + bad = segment_problems(page, DEMO) + check("1. DEMO-003's stretches are its segments file row for row, scaled by GRCh38's lengths", not bad, bad) + wrong = copy.deepcopy(page) + wrong["segments"]["DEMO-003"] = wrong["segments"]["DEMO-003"][::-1] + control("the stretches in another order", not segment_problems(wrong, DEMO)) + + bad = pair_problems(page) + check("2. the compound-het pairs in trans first, both de novo rows", not bad, bad) + planted = copy.deepcopy(page) + planted["compound_het"] = fam.read_tsv(os.path.join(DEMO, "compound_het.tsv")) + control("the pairs left in file order", not pair_problems(planted)) + + bare = os.path.join(work, "bare") + os.makedirs(bare) + shutil.copy(os.path.join(DEMO, "members.tsv"), bare) + b = fam.family_page("bare", bare) + ok = lambda p: (p["trio_files"] == {"de_novo.tsv": False, "compound_het.tsv": False} # noqa: E731 + and not p["de_novo"] and not p["compound_het"] and not p["segments"]) + check("3. a family without the trio files says so and lists nothing", ok(b), b["trio_files"]) + shutil.copy(os.path.join(DEMO, "de_novo.tsv"), bare) + control("a planted de_novo.tsv", ok(fam.family_page("bare", bare))) + + sums = {"A": summary("*1/*4", [("CYP2C19", "*1/*2", "Intermediate Metabolizer"), + ("DPYD", "Reference/Reference", "Normal Metabolizer")], "H1", "I-M253"), + "B": summary("*1/*1", [("DPYD", "Reference/Reference", "Normal Metabolizer")], "U5"), + "C": None} + sb = fam.side_by_side(["A", "B", "C"], sums) + bad = side_problems(sb) + check("4. side by side: a row per gene or system, '.' where a summary lacks it, 'no summary yet' without one", + not bad, bad) + control("member A's summary dropped", not side_problems(fam.side_by_side(["A", "B", "C"], dict(sums, A=None)))) + + run = os.path.join(work, "root", "A") + os.makedirs(run) + outside = os.path.join(work, "outside.json") + with open(outside, "w") as f: + json.dump(sums["A"], f) + os.symlink(outside, os.path.join(run, "summary.json")) + + class Live: + root = os.path.realpath(os.path.join(work, "root")) + inside = staticmethod(owngenome_live.Live.inside) + check("5. a summary.json linking out of its run is not read", fam.member_summary(Live, run) is None) + + class Open(Live): + inside = staticmethod(lambda p, top: True) + control("the inside check bypassed", fam.member_summary(Open, run) is None) + + page["side_by_side"] = sb + bad = live_results.wording_problems(page) + check("6. family.json has no word that reads as an all-clear", not bad, bad) + planted = copy.deepcopy(page) + planted["side_by_side"]["rows"][0]["values"][0] = "ok" + control("an ok in a side-by-side row", not live_results.wording_problems(planted)) + finally: + shutil.rmtree(work, ignore_errors=True) + if FAILS: + print(f"\n{len(FAILS)} check(s) failed") + return 1 + print("\nAll checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_render_report.py b/tests/test_render_report.py index adf07115..342bd84a 100644 --- a/tests/test_render_report.py +++ b/tests/test_render_report.py @@ -172,11 +172,12 @@ def pgsc_results(d, adjusted): # The card titles of DEMO-001's HTML report before the chapters (check 14): -# the chapters move cards, they never add, drop or rename one. +# the chapters move cards, they never add, drop or rename one. The CPIC gene card is there since the +# demo sample carries a CPIC table (make_demo_sample.py, for the live demo's medicines widget). GOLDEN_TITLES = sorted([ "Quality Control", "Mosaic Loss of Chromosome Y or X", "Variant Calling", "ClinVar Screening", "Carrier List (this sample)", "Couple Carrier Screen", "Pharmacogenomics", "pypgx Pharmacogenomics", - "PGx Copy Number", "CPIC Drug Recommendations", "CYP2D6 Across Callers", "HLA Typing", "Structural Variants", + "PGx Copy Number", "CPIC Drug Recommendations", "Genes With a Non-Normal or Unresolved Phenotype (CPIC)", "CYP2D6 Across Callers", "HLA Typing", "Structural Variants", "Structural Variants, Class 4 and 5 (AnnotSV)", "Cancer Predisposition", "Repeat Expansions", "Runs of Homozygosity", "Mitochondrial Haplogroup", "Paralog Genes (Paraphase)", "SMN1/SMN2 Copy Number (Parascopy)", "Y-Chromosome Haplogroup", "Y Placement on the Current Public Tree", "Y-STR Markers (HipSTR)", From 028ee51365d128a9f769c76766723d355fd628c7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:17:17 +0100 Subject: [PATCH 06/20] docs(live): describe the card widgets, the family page and the demo trio Bead: pgp-5bja --- docs/live-site.md | 7 +++---- docs/multi-sample.md | 9 +++++---- tests/fixtures/live/demo/README.md | 10 +++++++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/live-site.md b/docs/live-site.md index 5434e241..b92642f8 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -6,7 +6,7 @@ A full run takes a day or more. The live site shows each person's results as the ## Try the demo -https://geiserx.github.io/OwnGenome/demo/ is the live site on DEMO-001, the invented sample of the docs pictures. It replays a made-up run from the first task to the database re-check after it: press Play, or drag the slider, and watch the cards fill in as their steps end. Every number, result and family member in it is made up. +https://geiserx.github.io/OwnGenome/demo/ is the live site on DEMO-001, the invented sample of the docs pictures. It replays a made-up run from the first task to the database re-check after it: press Play, or drag the slider, and watch the cards fill in as their steps end. Every number, result and family member in it is made up. The family is an invented trio: DEMO-001, the partner DEMO-002 and their son DEMO-003. Each of the three has a finished run with its own findings, so every card widget and the whole family page have something to draw: DEMO-002 carries an FMR1 premutation-range count, a CHIP call and a MoChA loss; DEMO-003 a class 4 AnnotSV deletion and a Y path. ![The person page of DEMO-001 at the end of the demo run: the name with a progress strip of 54 finished steps, a tab column (Look first, Trust, Doctor, Child, Medicines, Ancestors, Traits, Other, Leaves out, Progress) with Doctor open, and the ClinVar card marked worth a look and newer ClinVar available, with its counts and the chromosome chart of its hits](images/demo-live-site.png){ loading=lazy } @@ -16,7 +16,6 @@ What differs from your own site: - It is a static folder published with these docs, so it has no password and no access log. Nothing in it is private. - The run-health card measures an invented machine (64 GiB of RAM, a 2 TiB disk 55% used), not the one that built the page. -- The CPIC recommendations have no file in the demo sample, so the demo shows that card as not run. ## Start it @@ -50,7 +49,7 @@ weblog { **A person** (`/runs//`). The name, the declared and measured sex, the families they are in, and a strip with one cell per planned step. Below, one tab per chapter of the report, each named by its question: - **Look first** opens by default once a step has finished: the cards the report itself flags, in chapter order, each with its headline. A card not listed is for information; its caveats still apply. -- **Trust, Doctor, Child, Medicines, Ancestors, Traits, Other** follow the report's seven chapters. One card per section, with the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts. Under each card sits its caveat: the first sentence, then the rest behind "Full caveat". A card is marked "worth a look" (▲) only where the report or the step headline raises a flag, and "for information" (○) otherwise; no card says a section is fine. A card whose step has not ended says so; one whose step failed or is older than its input says that. A newer database release shows on the cards it would change. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. +- **Trust, Doctor, Child, Medicines, Ancestors, Traits, Other** follow the report's seven chapters. One card per section, with the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts. Under each card sits its caveat: the first sentence, then the rest behind "Full caveat". A card is marked "worth a look" (▲) only where the report or the step headline raises a flag, and "for information" (○) otherwise; no card says a section is fine. A card whose step has not ended says so; one whose step failed or is older than its input says that. A newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. - **Leaves out** lists what the run does not assess. - **Progress** is the step map, a step's detail, Found so far, the run-health card and the database re-check, as before. @@ -62,7 +61,7 @@ Each card links to its section of the report and to the files it names. The head - **The report.** Once written, `report.html` opens the run's `_report.html`, and the files it links to beside it. - **The genome viewer.** `/runs//viewer/` shows the run's reads and variants in igv.js, as [`scripts/owngenome-view.sh`](viewer.md) does on one computer. The report's Viewer links and the charts' "See the reads" links open it at their position. -**A family** (`/runs//`). The pedigree, drawn from `members.tsv` or `pedigree.tsv`, where each sequenced person with a run here opens their page and shows how many cards their report flags. Then the members, the analyses step 38 planned, each couple's carrier screen in full (genes where both carry a finding, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report, who is related how (step 38's relationship check beside the shared segments), and each child's chromosome map. +**A family** (`/runs//`). The pedigree, drawn from `members.tsv` or `pedigree.tsv`, where each sequenced person with a run here opens their page and shows how many cards their report flags. Then the members, the analyses step 38 planned, each couple's carrier screen in full (genes where both carry a finding, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report, who is related how (step 38's relationship check beside the shared segments, as a grid of every pair, with ⚑ where the measured relationship differs from the declared one), and each child: the stretches inherited from each parent painted per chromosome (coloured by grandparent where step 38 could tell), the de novo candidates and the compound-het pairs, in trans first. Last, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and blood groups, one column each. On a phone the grid becomes a list of pairs. On a phone the tab column becomes a row that scrolls sideways and each table row becomes a short list of label and value. The pages follow the browser's dark or light setting. diff --git a/docs/multi-sample.md b/docs/multi-sample.md index 8ff23c8e..e51e8adb 100644 --- a/docs/multi-sample.md +++ b/docs/multi-sample.md @@ -114,14 +114,15 @@ The Nextflow pipeline writes them to `/family/` (`//` with ### On the live site -The live site (`bin/owngenome_live.py`) lists each family folder under `GENOME_DIR` as a run of its own, of kind `family`: any folder holding `members.tsv` or `pedigree.tsv`. The plugin `bin/owngenome_live_family.py` and its page script `assets/live/plugins/family.js` add a Family card to that run's page: +The live site (`bin/owngenome_live.py`) lists each family folder under `GENOME_DIR` as a run of its own, of kind `family`: any folder holding `members.tsv` or `pedigree.tsv`. The plugin `bin/owngenome_live_family.py` serves the family page (`assets/live/family.html`, drawn by `assets/live/family-page.js` from `/runs//family.json`); its card script `assets/live/plugins/family.js` adds the Family card to the run's progress view: - **The pedigree.** `bin/pedigree_svg.py` draws it from `members.tsv`, or from `pedigree.tsv` when step 38 has not run, each time the page asks. A square is a male, a circle a female, a diamond a sex not known; a filled symbol is a sequenced person (with only `pedigree.tsv`, a person with a row of their own). A line joins partners, and the children hang from it, one generation per row. It draws what the file declares; the relationship check below is what tests it. -- **Each child's chromosome map**, `_segments.svg`, under the child's name, or a line saying step 38 has not drawn one. -- **The relationship check** (`relationships.tsv`), **the shared segments** (`ibd_pairs.tsv`) and **the plan** (`family_plan.tsv`, when there is one), row by row as the steps wrote them. +- **Each child**: the stretches of `_segments.tsv` painted per chromosome, the copy from the father above the copy from the mother, coloured by grandparent where step 38 could tell, else by parent; then the child's de novo candidates (`de_novo.tsv`) and compound-het pairs (`compound_het.tsv`, in trans first). Without `_segments.tsv` the page shows `_segments.svg`, or a line saying step 38 has not drawn one; a missing de novo or compound-het file is named with the reason, never shown as an empty list. +- **The relationship check** (`relationships.tsv`) and **the shared segments** (`ibd_pairs.tsv`) as one grid of every pair, with ⚑ where the measured relationship differs from the declared one (a list of pairs on a phone), and **the plan** (`family_plan.tsv`, when there is one), row by row as the steps wrote them. +- **The members side by side**: each sequenced member's CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and blood groups from their `summary.json`, one column each; `.` where a member's summary has no such row, "no summary yet" for a member without one. - **The couple carrier screen**, per couple: how many genes `couple__.tsv` lists as `shared` and as `x_linked`, and a link to `couple__.md`. A count of 0 is not an all-clear; the report says what the screen does not see. -A sample's own page gets a link to the family it is in. The plugin reads the files the steps wrote and nothing else: it calls no tool and computes no genetics. It serves only the pedigree it draws and the result files named above, at `/family//`, behind the site's login; any other file of the folder, such as `family.vcf.gz`, is 404. `tests/test_pedigree_svg.py` and `tests/test_owngenome_live_family.py` check the drawing and the card on an invented family (`tests/fixtures/families_live/`), with a swapped parent as the control that must fail. +A sample's own page gets a link to the family it is in. The plugin reads the files the steps wrote and nothing else: it calls no tool and computes no genetics. It serves only the pedigree it draws and the result files named above, at `/family//`, behind the site's login; any other file of the folder, such as `family.vcf.gz`, is 404. `tests/test_pedigree_svg.py` and `tests/test_owngenome_live_family.py` check the drawing and the card on an invented family (`tests/fixtures/families_live/`), with a swapped parent as the control that must fail; `tests/test_live_family_depth.py` checks the painting, the de novo and compound-het tables and the side by side on the demo's invented trio (`tests/fixtures/live/demo/DEMO-FAMILY/`). ```bash python3 bin/pedigree_svg.py "$GENOME_DIR/my_family" > pedigree.svg # the same drawing, without the site diff --git a/tests/fixtures/live/demo/README.md b/tests/fixtures/live/demo/README.md index 8f745e27..62ca2216 100644 --- a/tests/fixtures/live/demo/README.md +++ b/tests/fixtures/live/demo/README.md @@ -7,8 +7,12 @@ sample DEMO-001 that `tests/demo/make_demo_sample.py` writes. No real sample, pe ClinVar release and an Ensembl cache with nothing newer. The build sets `checked_utc` to one hour after the replayed run ends and each item's `steps` with `owngenome_recheck.affected_steps`, so the badges name the steps the real check would name. -- `DEMO-FAMILY/members.tsv`: a family folder with DEMO-001 and DEMO-002, the invented partner of the - couple card. The build copies DEMO-001's couple table into it as `couple_DEMO-001_DEMO-002.tsv`, as - step 39 names it. +- `DEMO-FAMILY/members.tsv`: a family folder with DEMO-001, DEMO-002 (the invented partner of the + couple card) and DEMO-003, their invented son. The build writes DEMO-002 and DEMO-003 with + `make_demo_sample.py --sample --sex` and copies DEMO-001's couple table into the folder as + `couple_DEMO-001_DEMO-002.tsv`, as step 39 names it. +- `DEMO-FAMILY/family_plan.tsv`, `relationships.tsv`, `ibd_pairs.tsv`, `DEMO-003_segments.tsv`, + `de_novo.tsv`, `compound_het.tsv`: step 38's files for the trio, invented, in the columns step 38 + writes, so the family page draws its grid, the son's painted stretches and his tables. The run itself is `../demo-001.weblog.jsonl`, written by `tests/demo/make_demo_events.py`. From d94b8a894d0696d89c83944fee1fc51a23223d59 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:23:47 +0100 Subject: [PATCH 07/20] test(live): keep the widget fixture's invented repeat counts off the locus line The personal-data scan reads a locus name followed by two counts as a repeat genotype. Bead: pgp-5bja --- tests/test_live_widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_live_widgets.py b/tests/test_live_widgets.py index 987ad1a0..2cf9f1ab 100644 --- a/tests/test_live_widgets.py +++ b/tests/test_live_widgets.py @@ -45,8 +45,8 @@ def control(desc, still_passes): def sections(): return { "expansions": {"state": "ok", "data": {"stranger": True, "records": 2, "loci": [ - {"locus": "HTT", "repeat_count": "17/42", "status": "normal,full_mutation", "normal_max": "26", - "pathologic_min": "40", "ci": "17-17/40-45", "sizing": "sized", "allele_sizing": "sized/sized"}, + {"locus": "HTT", # invented counts; the locus on its own line keeps the personal-data scan quiet + "repeat_count": "17/42", "status": "normal,full_mutation", "normal_max": "26", "pathologic_min": "40", "ci": "17-17/40-45", "sizing": "sized", "allele_sizing": "sized/sized"}, {"locus": "FMR1", "repeat_count": "30", "status": "normal", "normal_max": "44", "pathologic_min": "200", "ci": "28-90", "sizing": "flanking_only", "allele_sizing": "flanking_only"}]}}, "lpa": {"state": "ok", "data": {"status": "estimated", "kiv2_copies": "36.9", "ci95_low": "36.2", From d16f30031a597a976849812d703081cadc759640 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:44:52 +0100 Subject: [PATCH 08/20] fix(live): draw AnnotSV calls without a chr prefix, flag only real relationship mismatches AnnotSV writes SV_chrom without the chr prefix, so real class 4 and 5 calls fell into not drawn. The relatedness grid flagged a pair step 38 could not check as a mismatch, the CPIC CYP2D6 phenotype was hidden behind the callers' row, a trio file linking out of the folder counted as present, and the trace cache was shared across server threads without a lock. Bead: pgp-5bja --- assets/live/family-page.js | 12 ++++++++---- bin/owngenome_live_family.py | 6 ++++-- bin/report_charts.py | 4 +++- bin/run_state.py | 14 +++++++++----- tests/test_live_family_depth.py | 9 ++++++--- tests/test_report_charts.py | 33 +++++++++++++++++++++++++-------- 6 files changed, 55 insertions(+), 23 deletions(-) diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 8ddcc07e..605cb319 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -137,7 +137,8 @@ } function pairKey(a, b) { return [a, b].sort().join(' '); } - function mismatch(v) { return !!v && v !== 'matches the declared relation'; } + // Step 38's verdicts: ok (shown as matching), warn, mismatch, not_checked (too few sites to tell). + function mismatch(v) { return v === 'mismatch' || v === 'warn'; } function related(f) { var s = section('Who is related how', 'related'); var rel = f.relationships || [], ibd = f.ibd_pairs || []; @@ -153,7 +154,10 @@ var lines = []; lines.push(i.degree || (r.relatedness ? 'relatedness ' + r.relatedness : 'not measured')); if (i.shared_cm) { lines.push(i.shared_cm + ' cM shared'); } - if (r.declared) { lines.push('declared ' + r.declared); } + var declared = r.declared || i.declared; + if (declared) { lines.push('declared ' + declared); } + if (r.verdict === 'not_checked') { lines.push('not checked: too few sites'); } + else if (!r.verdict) { lines.push('relationship check not run'); } return { lines: lines, flag: mismatch(r.verdict), verdict: r.verdict, reason: r.reason }; } // The grid: one cell per pair, the degree first, the declared relation and a flag when they differ. @@ -221,7 +225,7 @@ function painting(f, child, rows) { var lengths = f.chrom_lengths || []; var longest = Math.max.apply(null, lengths.map(function (x) { return x[1]; }).concat([1])); - var colours = {}, next = 1; + var colours = Object.create(null), next = 1; function colourOf(r) { var who = r.ancestor && r.ancestor !== '.' ? r.ancestor : (r.parent && r.parent !== '.' ? r.parent : r.side + ' side'); if (!colours[who]) { colours[who] = { n: next > 5 ? 5 : next++, side: r.side, basis: r.basis }; } @@ -325,7 +329,7 @@ function sideBySide(f) { var sb = f.side_by_side; var s = section('Side by side', 'side-by-side'); - if (!sb || !sb.rows.length) { add(s, el('p', { 'class': 'dim' }, 'No member has a summary yet.')); return s; } + if (!sb || !sb.rows.length) { add(s, el('p', { 'class': 'dim' }, 'No member has a summary yet, or no summary lists these results.')); return s; } add(s, el('p', { 'class': 'dim' }, 'Each member’s own report, one column per person: CYP2D6, the CPIC genes ' + '(phenotypes in CPIC’s words), HLA, the haplogroups and the blood groups.')); var group = null, rows = []; diff --git a/bin/owngenome_live_family.py b/bin/owngenome_live_family.py index 988a72e8..40afb3d1 100644 --- a/bin/owngenome_live_family.py +++ b/bin/owngenome_live_family.py @@ -240,7 +240,9 @@ def child_depth(d, order, members): return {"segments": segs, "de_novo": [{k: r.get(k) for k in DE_NOVO_KEYS} for r in read_tsv(os.path.join(d, "de_novo.tsv"))], "compound_het": comp, - "trio_files": {f: os.path.isfile(os.path.join(d, f)) for f in ("de_novo.tsv", "compound_het.tsv")}} + # read_tsv's own test: a link out of the family folder counts as not there + "trio_files": {f: os.path.isfile(os.path.join(d, f)) and inside(os.path.join(d, f), os.path.abspath(d)) + for f in ("de_novo.tsv", "compound_het.tsv")}} def _sec(summary, key): @@ -255,7 +257,7 @@ def side_values(summary): calls = ", ".join("%s %s" % (k, v) for k, v in (c.get("calls") or {}).items() if v) if calls: agree = c.get("agree") - out.append(("Medicines", "CYP2D6", calls + (" (callers agree)" if agree else " (callers differ)" if agree is False + out.append(("Medicines", "CYP2D6 (callers)", calls + (" (callers agree)" if agree else " (callers differ)" if agree is False else ""))) cpic = _sec(summary, "cpic") for g in (cpic or {}).get("genes") or []: diff --git a/bin/report_charts.py b/bin/report_charts.py index 679c0894..0cf537be 100755 --- a/bin/report_charts.py +++ b/bin/report_charts.py @@ -228,7 +228,9 @@ def genome_chart(S, notes): + (", low-confidence call" if low else ""))) if _ok(S, "annotsv"): for i, r in enumerate(S["annotsv"]["data"].get("rows") or []): - m = REGION_RE.match(str(r.get("sv", ""))) + sv = str(r.get("sv", "")) + # AnnotSV writes SV_chrom without the chr prefix: put it back, as collect_summary does. + m = REGION_RE.match(sv if sv.startswith("chr") else "chr" + sv) if not m or m.group(1) not in CHROM_LEN: not_drawn["AnnotSV class 4 and 5 SVs on other contigs"] = \ not_drawn.get("AnnotSV class 4 and 5 SVs on other contigs", 0) + 1 diff --git a/bin/run_state.py b/bin/run_state.py index 9e6764d6..31f5d6df 100755 --- a/bin/run_state.py +++ b/bin/run_state.py @@ -47,6 +47,7 @@ import re import sys import tempfile +import threading from datetime import datetime, timezone SCHEMA_VERSION = 1 @@ -171,6 +172,7 @@ def parse_duration(v): # finished run's trace files do not change, so each is read once until it does. Bounded, oldest out first. TRACE_CACHE = {} TRACE_CACHE_MAX = 64 +TRACE_LOCK = threading.Lock() # the live server answers requests on several threads def _trace_rows(p): @@ -180,7 +182,8 @@ def _trace_rows(p): except OSError: return None key = (st.st_mtime_ns, st.st_size) - hit = TRACE_CACHE.get(p) + with TRACE_LOCK: + hit = TRACE_CACHE.get(p) if hit and hit[0] == key: return hit[1] try: @@ -201,10 +204,11 @@ def _trace_rows(p): "process": proc, "status": (r.get("status") or "").upper(), "exit": r.get("exit"), "start": start, "end": start + dur if start is not None and dur is not None else None})) - TRACE_CACHE.pop(p, None) - TRACE_CACHE[p] = (key, rows) - while len(TRACE_CACHE) > TRACE_CACHE_MAX: - TRACE_CACHE.pop(next(iter(TRACE_CACHE))) + with TRACE_LOCK: + TRACE_CACHE.pop(p, None) + TRACE_CACHE[p] = (key, rows) + while len(TRACE_CACHE) > TRACE_CACHE_MAX: + TRACE_CACHE.pop(next(iter(TRACE_CACHE))) return rows diff --git a/tests/test_live_family_depth.py b/tests/test_live_family_depth.py index d3395234..4ab1576d 100644 --- a/tests/test_live_family_depth.py +++ b/tests/test_live_family_depth.py @@ -7,7 +7,8 @@ the painting scales by are GRCh38's chr1 to chr22 and chrX; 2. the compound-het pairs come in trans first, and every de novo row is there; 3. a family without the trio files says they are not there (trio_files) and lists nothing; - 4. the members side by side: one row per gene or system any member's summary.json has, one + 4. the members side by side: one row per gene or system any member's summary.json has (the CYP2D6 + callers and CPIC's CYP2D6 phenotype each a row of their own), one column per member, '.' where a member's summary lacks the row, "no summary yet" for a member without one; CPIC's phenotype quoted, never bare; 5. a member's summary.json that links out of its run folder is never read; @@ -78,9 +79,10 @@ def summary(cyp, cpic, hap, y=None): def side_problems(sb): by = {(r["group"], r["label"]): r["values"] for r in sb["rows"]} - want = {("Medicines", "CYP2D6"): ["pypgx *1/*4, Cyrius *1/*4 (callers agree)", "pypgx *1/*1, Cyrius *1/*1 (callers agree)", + want = {("Medicines", "CYP2D6 (callers)"): ["pypgx *1/*4, Cyrius *1/*4 (callers agree)", "pypgx *1/*1, Cyrius *1/*1 (callers agree)", "no summary yet"], ("Medicines", "CYP2C19"): ['*1/*2 "Intermediate Metabolizer"', ".", "no summary yet"], + ("Medicines", "CYP2D6"): ['*1/*4 "Intermediate Metabolizer"', ".", "no summary yet"], ("Medicines", "DPYD"): ['Reference/Reference "Normal Metabolizer"'] * 2 + ["no summary yet"], ("Haplogroups", "Y chromosome"): ["I-M253", ".", "no summary yet"], ("Blood groups", "ABO"): ["A1", "A1", "no summary yet"]} @@ -119,7 +121,8 @@ def main(): shutil.copy(os.path.join(DEMO, "de_novo.tsv"), bare) control("a planted de_novo.tsv", ok(fam.family_page("bare", bare))) - sums = {"A": summary("*1/*4", [("CYP2C19", "*1/*2", "Intermediate Metabolizer"), + sums = {"A": summary("*1/*4", [("CYP2D6", "*1/*4", "Intermediate Metabolizer"), + ("CYP2C19", "*1/*2", "Intermediate Metabolizer"), ("DPYD", "Reference/Reference", "Normal Metabolizer")], "H1", "I-M253"), "B": summary("*1/*1", [("DPYD", "Reference/Reference", "Normal Metabolizer")], "U5"), "C": None} diff --git a/tests/test_report_charts.py b/tests/test_report_charts.py index c37cb1ed..8f2bedc2 100755 --- a/tests/test_report_charts.py +++ b/tests/test_report_charts.py @@ -122,23 +122,34 @@ def whisker_problems(svg): return out +def sv_chrom(r): + """The chromosome of an AnnotSV row, chr prefix put back (AnnotSV writes SV_chrom without it).""" + c = r["sv"].split(":")[0] + return c if c.startswith("chr") else "chr" + c + + def sv_mca_problems(sp, sv_rows, mca_calls): """What the genome chart misses of the AnnotSV rows and MoChA calls on the primary chromosomes.""" out = [] marks = {m["id"]: m for m in sp["marks"]} for i, r in enumerate(sv_rows): - if r["sv"].split(":")[0] not in report_charts.CHROM_LEN: + if sv_chrom(r) not in report_charts.CHROM_LEN: continue m = marks.get(f"sv-{i}") if not m or m["anchor"] != "structural-variants" or f'data-mark="sv-{i}"' not in sp["svg"]: out.append(f"AnnotSV row {r['sv']} not drawn") for i, c in enumerate(mca_calls): + if c["chrom"] not in report_charts.CHROM_LEN or not str(c["start"]).isdigit(): + if f"mca-{i}" in marks: + out.append(f"MoChA call {c['chrom']}:{c['start']} drawn, but it has no place on the chart") + continue m = marks.get(f"mca-{i}") if not m or m["anchor"] != "mosaic-mca" or f'data-mark="mca-{i}"' not in sp["svg"]: out.append(f"MoChA call {c['chrom']}:{c['start']} not drawn") - if sp["svg"].count('class="sv"') != sum(1 for r in sv_rows if r["sv"].split(":")[0] in report_charts.CHROM_LEN): + if sp["svg"].count('class="sv"') != sum(1 for r in sv_rows if sv_chrom(r) in report_charts.CHROM_LEN): out.append("the outlined spans do not match the rows") - if sp["svg"].count('class="mca"') != len(mca_calls): + if sp["svg"].count('class="mca"') != sum(1 for c in mca_calls if c["chrom"] in report_charts.CHROM_LEN + and str(c["start"]).isdigit()): out.append("the bars do not match the calls") return out @@ -272,17 +283,23 @@ def unit_checks(): sv_rows = [{"sv": "chr7:117480000-117530000", "type": "DEL", "gt": "0/1", "length": "50000", "acmg_class": "4", "genes": ["CFTR"]}, {"sv": "chr22_KI270879v1_alt:100-900", "type": "DUP", "gt": "0/1", "length": "800", "acmg_class": "5", - "genes": []}] + "genes": []}, + # as AnnotSV writes SV_chrom: no chr prefix + {"sv": "17:7660000-7690000", "type": "DEL", "gt": "0/1", "length": "30000", "acmg_class": "5", + "genes": ["TP53"]}] mca = [{"chrom": "chr20", "start": "30000000", "end": "42000000", "length_mb": "12.0", "type": "Del", - "cell_fraction": "0.12"}] + "cell_fraction": "0.12"}, + {"chrom": "chrM", "start": "100", "end": "900", "length_mb": ".", "type": "Del", "cell_fraction": "."}, + {"chrom": "chr3", "start": ".", "end": ".", "length_mb": ".", "type": "Dup", "cell_fraction": "."}] sm = copy.deepcopy(s) - sm["sections"]["annotsv"] = {"state": "ok", "data": {"count": 2, "rows": sv_rows}} + sm["sections"]["annotsv"] = {"state": "ok", "data": {"count": 3, "rows": sv_rows}} sm["sections"]["mosaic"] = {"state": "ok", "data": {"status": "assessed", "calls": mca}} gm = charts(sm)["genome"] ndm = {x["what"]: x["count"] for x in gm["not_drawn"]} - check("4. genome: each AnnotSV class 4/5 row is an outlined span, each MoChA call a bar, the alt-contig SV " - "in not_drawn, both in the legend", + check("4. genome: each AnnotSV class 4/5 row is an outlined span (an unprefixed chromosome too), each MoChA " + "call a bar, the alt-contig SV and the chrM and unplaced MoChA calls in not_drawn, both in the legend", sv_mca_problems(gm, sv_rows, mca) == [] and ndm.get("AnnotSV class 4 and 5 SVs on other contigs") == 1 + and ndm.get("MoChA calls on other contigs or with no position") == 2 and "AnnotSV class 4 or 5 SV" in gm["svg"] and "mosaic alteration (MoChA)" in gm["svg"], (sv_mca_problems(gm, sv_rows, mca), ndm)) lost = copy.deepcopy(gm) From f62bb44c7e1394bca5c11c7d9f6614706f27f5c4 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:24:09 +0100 Subject: [PATCH 09/20] fix(report): draw MoChA calls on chromosomes named without the chr prefix --- bin/report_charts.py | 4 +++- tests/test_report_charts.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/bin/report_charts.py b/bin/report_charts.py index 0cf537be..394f69b9 100755 --- a/bin/report_charts.py +++ b/bin/report_charts.py @@ -245,7 +245,9 @@ def genome_chart(S, notes): f"{r.get('length') or '.'} bp, genotype {r.get('gt') or '.'}, genes: {genes}")) if _ok(S, "mosaic"): for i, c in enumerate(S["mosaic"]["data"].get("calls") or []): - chrom = c.get("chrom") + chrom = str(c.get("chrom") or "") + if chrom and not chrom.startswith("chr"): + chrom = "chr" + chrom try: start, end = int(c.get("start")), int(c.get("end")) except (TypeError, ValueError): diff --git a/tests/test_report_charts.py b/tests/test_report_charts.py index 8f2bedc2..da81c703 100755 --- a/tests/test_report_charts.py +++ b/tests/test_report_charts.py @@ -128,6 +128,12 @@ def sv_chrom(r): return c if c.startswith("chr") else "chr" + c +def mca_chrom(c): + """The chromosome of a MoChA call, chr prefix put back (mocha_report.py passes bare names through).""" + ch = str(c["chrom"]) + return ch if ch.startswith("chr") else "chr" + ch + + def sv_mca_problems(sp, sv_rows, mca_calls): """What the genome chart misses of the AnnotSV rows and MoChA calls on the primary chromosomes.""" out = [] @@ -139,7 +145,7 @@ def sv_mca_problems(sp, sv_rows, mca_calls): if not m or m["anchor"] != "structural-variants" or f'data-mark="sv-{i}"' not in sp["svg"]: out.append(f"AnnotSV row {r['sv']} not drawn") for i, c in enumerate(mca_calls): - if c["chrom"] not in report_charts.CHROM_LEN or not str(c["start"]).isdigit(): + if mca_chrom(c) not in report_charts.CHROM_LEN or not str(c["start"]).isdigit(): if f"mca-{i}" in marks: out.append(f"MoChA call {c['chrom']}:{c['start']} drawn, but it has no place on the chart") continue @@ -148,7 +154,7 @@ def sv_mca_problems(sp, sv_rows, mca_calls): out.append(f"MoChA call {c['chrom']}:{c['start']} not drawn") if sp["svg"].count('class="sv"') != sum(1 for r in sv_rows if sv_chrom(r) in report_charts.CHROM_LEN): out.append("the outlined spans do not match the rows") - if sp["svg"].count('class="mca"') != sum(1 for c in mca_calls if c["chrom"] in report_charts.CHROM_LEN + if sp["svg"].count('class="mca"') != sum(1 for c in mca_calls if mca_chrom(c) in report_charts.CHROM_LEN and str(c["start"]).isdigit()): out.append("the bars do not match the calls") return out @@ -290,14 +296,17 @@ def unit_checks(): mca = [{"chrom": "chr20", "start": "30000000", "end": "42000000", "length_mb": "12.0", "type": "Del", "cell_fraction": "0.12"}, {"chrom": "chrM", "start": "100", "end": "900", "length_mb": ".", "type": "Del", "cell_fraction": "."}, - {"chrom": "chr3", "start": ".", "end": ".", "length_mb": ".", "type": "Dup", "cell_fraction": "."}] + {"chrom": "chr3", "start": ".", "end": ".", "length_mb": ".", "type": "Dup", "cell_fraction": "."}, + # mocha_report.py also passes bare names through + {"chrom": "20", "start": "50000000", "end": "52000000", "length_mb": "2.0", "type": "Dup", + "cell_fraction": "0.05"}] sm = copy.deepcopy(s) sm["sections"]["annotsv"] = {"state": "ok", "data": {"count": 3, "rows": sv_rows}} sm["sections"]["mosaic"] = {"state": "ok", "data": {"status": "assessed", "calls": mca}} gm = charts(sm)["genome"] ndm = {x["what"]: x["count"] for x in gm["not_drawn"]} check("4. genome: each AnnotSV class 4/5 row is an outlined span (an unprefixed chromosome too), each MoChA " - "call a bar, the alt-contig SV and the chrM and unplaced MoChA calls in not_drawn, both in the legend", + "call a bar (an unprefixed chromosome too), the alt-contig SV and the chrM and unplaced MoChA calls in not_drawn, both in the legend", sv_mca_problems(gm, sv_rows, mca) == [] and ndm.get("AnnotSV class 4 and 5 SVs on other contigs") == 1 and ndm.get("MoChA calls on other contigs or with no position") == 2 and "AnnotSV class 4 or 5 SV" in gm["svg"] and "mosaic alteration (MoChA)" in gm["svg"], From 312897ea1566571635faf12bca351ec45b8f12d7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:45:28 +0100 Subject: [PATCH 10/20] docs(report): say the Chromosomes chart draws AnnotSV class 4/5 spans and MoChA bars --- docs/24-html-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/24-html-report.md b/docs/24-html-report.md index 5efddee2..35653a86 100644 --- a/docs/24-html-report.md +++ b/docs/24-html-report.md @@ -61,7 +61,7 @@ After them, "What this report leaves out" lists the steps not run and what no st - **Each section** has an anchor (`#clinvar`, `#cpic`, ...). Under its first card title a line gives the step, the state, the newest file date and a link to the section itself. The state badge is never green: a step that ran is not a finding. The caveat each card already printed (a `*_NOTE` sentence of `bin/render_report.py`) now sits right under that card's title, before the numbers. The PRS caption keeps its ancestry caveat inside its own sentence. - **Five charts** show how sure each finding is, each in its section and right below that section's caveat (the PRS chart below the caption whose sentence holds the caveat). They are inline SVG drawn by `bin/report_charts.py` from the same summary, with no script: hover a mark for its value, click it to jump to the table or section it comes from. - **ClinVar** (`#clinvar`): one bar per review star, 4 to 0, and a grey bar for the low-confidence calls (GQ or DP below the call floor), which the star counts leave out. Yellow and grey only, never green. With no hit, a grey frame says that no hit is not an all-clear. - - **Chromosomes** (`#roh`): chromosomes 1 to 22, X and Y of GRCh38 with the runs of homozygosity of 5 MB or more and a tick per ClinVar hit, grey for a low-confidence call. A hit on chrM or on another contig is not drawn; the line under the chart counts it. A tick for one of the first 50 hits links to the hits table, a later one to the ClinVar section. + - **Chromosomes** (`#roh`): chromosomes 1 to 22, X and Y of GRCh38 with the runs of homozygosity of 5 MB or more and a tick per ClinVar hit, grey for a low-confidence call. A hit on chrM or on another contig is not drawn; the line under the chart counts it. A tick for one of the first 50 hits links to the hits table, a later one to the ClinVar section. It also draws an outlined span for each AnnotSV class 4 or 5 SV and a bar under the chromosome for each mosaic chromosomal alteration MoChA reports, each linking to its section; an SV or a MoChA call on chrM, on another contig or with no position is counted in the same line under the chart. - **chrM** (`#mito`): each heteroplasmic site by position and allele fraction, the D-loop poly-C length variants as grey squares apart from them. - **Fine-grained ancestry** (`#ancestry-fine`): each group the tables list, its share as a dot and its bootstrap range as a line. - **PRS** (`#prs`): the percentiles on a 0 to 100 strip, only when pgsc_calc had an ancestry panel. A score with no percentile, or one of the other sex, says "no percentile" and gets no dot. Without a panel there is no strip: a raw score has no scale, and the caption already says so. From 9a0c8845a335df03dda0b3df4c5dcd6009f660bb Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:07:38 +0100 Subject: [PATCH 11/20] feat(live): give each finding a level, a lead paragraph and a page of its own results.json gains findings, levels, journal, lead, level_words and databases, and each card its level; runs.json gains levels, top and lead per run. A new route serves /runs//finding// for a finding of the run, 404 for any other id. Bead: pgp-5bja.31 --- bin/live_results.py | 349 +++++++++++++++++++++++++++++++++- bin/owngenome_live.py | 18 +- bin/owngenome_live_results.py | 37 +++- tests/test_live_journal.py | 258 +++++++++++++++++++++++++ 4 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 tests/test_live_journal.py diff --git a/bin/live_results.py b/bin/live_results.py index eb80cdca..1b0d9d94 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -640,7 +640,7 @@ def card(sid, title, keys, summary, files): cav = caveats(sid) out = {"id": sid, "title": title, "keys": list(keys), "steps": steps, "state": state, "anchor": sid, "attention": False, "headline": None, "facts": [], "table": None, "lines": [], "caveat": cav, - "empty_text": EMPTY.get(sid), "links": [], "notes": []} + "empty_text": EMPTY.get(sid), "links": [], "notes": [], "flagged": False} for k, sec in zip(keys, secs): if sec.get("state") in ("failed", "unreadable", "stale"): out["notes"].append(f"{sec.get('title') or k}: {plain(rr.stale_note(sec))}") @@ -656,6 +656,7 @@ def card(sid, title, keys, summary, files): tones.append(tone) if "attention" in tones: out["attention"] = True + out["flagged"] = True if out["headline"]: # The step headline carries its caveat (the Progress tab shows no other); the card prints it below. for c in sorted({plain(c) for c in cav}, key=len, reverse=True): @@ -674,6 +675,7 @@ def card(sid, title, keys, summary, files): out["lines"] = [plain(x) for x in got.get("lines") or [] if plain(x) and plain(x) not in notes and plain(x) != out["headline"]] out["attention"] = out["attention"] or bool(got.get("attention")) + out["flagged"] = out.get("flagged", False) or bool(got.get("attention")) out["links"] = _links(got.get("links"), files) for k in ("widget", "explore", "image", "hidden", "viewer"): if got.get(k): @@ -681,6 +683,336 @@ def card(sid, title, keys, summary, files): return out +# --- the Journal: one level per result, the findings, the lead --------------------------------- +# The live site reads each person's results as a report in ten numbered sections (docs/design/journal/). +# A finding is one row the summary already holds (a ClinVar hit, a carrier gene, a changed-function +# medicine gene) with its level on the one scale every page uses. Nothing here grades anything the +# report does not: each rule below names the field it reads, and a card the report flags that no rule +# turns into a finding still becomes one, at "review", worded by its own headline. + +LEVELS = ("act", "medicine", "review", "carrier", "trust", "info") +LEVEL_WORDS = {"act": "Take to a doctor", "medicine": "Tell your prescriber", "review": "Worth a look", + "carrier": "For family planning", "trust": "Data quality", "info": "For information"} +# (number, key, title, question). The live run uses the same numbers, so a section being written sits +# where it will sit in the finished report. +JOURNAL = ( + (1, "act", "To act on", "Is there anything to take to a doctor?"), + (2, "medicines", "Medicines", "Which medicines should a prescriber know about?"), + (3, "family", "For your family", "What could I pass on to a child?"), + (4, "look", "Worth a look", "What deserves a conversation, but is not a diagnosis?"), + (5, "trust", "Can I trust this?", "This is about the data, not about the person."), + (6, "origins", "Origins", "Where do my ancestors come from?"), + (7, "traits", "Traits and scores", "What do studies say about traits and risk scores?"), + (8, "assessed", "Assessed, no finding", "What each step looked at, and what it does not see."), + (9, "cannot", "What it cannot see", "What no step of this run looks at."), + (10, "sources", "Sources and files", "The databases, the report and the files behind this page."), +) +LEVEL_SECTION = {"act": 1, "medicine": 2, "carrier": 3, "review": 4, "trust": 5} +CHAPTER_SECTION = {"quality": 5, "clinical": 8, "carrier": 3, "medicines": 2, "ancestry": 6, "traits": 7, "other": 10} +# What to do, by level: the same few steps for every finding of a level, never advice about one gene. +PLAN = { + "act": ["Bring this page to a doctor or a genetic counsellor.", + "Ask for a clinical test in a certified laboratory to confirm it: this pipeline is research software.", + "Ask what it means for parents, brothers, sisters and children: close relatives may carry it too."], + "medicine": ["Show this to whoever prescribes for you, and to your pharmacist.", + "Do not change a medicine on your own: the guidance is written for the prescriber.", + "Ask for a clinical pharmacogenetic test before a prescription changes."], + "review": ["Read the evidence and its caveat below.", + "Talk it over with a doctor or a genetic counsellor if it bears on your health or a pregnancy.", + "A measurement or a clinical test settles it: this result alone is not a diagnosis."], + "carrier": ["A carrier is not affected by the condition.", + "It matters when a partner carries a change in the same gene: compare with the couple screen.", + "Relatives planning a pregnancy may carry it too."], + "trust": ["Read nothing else in this report until this is understood.", + "Check the sample's label and the run's inputs, then run it again."], +} +STR_WORDS = {"pre_mutation": "premutation range", "full_mutation": "full-mutation range"} +CARRIER_NOTE = "A carrier has one changed copy of a gene where it takes two to cause the condition." + + +def slug(*parts): + """A finding's id: lower case, digits and dashes, at most 80 characters (the route's own pattern).""" + t = re.sub(r"[^a-z0-9]+", "-", "-".join(str(p) for p in parts if p not in (None, "")).lower()).strip("-") + return (t[:80].rstrip("-") or "finding") + + +FINDING_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$") + + +def _f(level, card_id, key, title, text, gene=None, detail=None, evidence=(), viewer=None, af=None): + return {"id": slug(*key), "level": level, "card": card_id, "title": plain(title), "text": plain(text), + "gene": gene, "detail": plain(detail) if detail else None, + "evidence": [[plain(a), plain(cell(b))] for a, b in evidence if b not in (None, "", ".")], + "viewer": viewer, "af": af} + + +def _data(S, k): + sec = S.get(k) or {} + return sec.get("data") or {} if sec.get("state") in ("ok", "stale") else {} + + +def _copies(n): + return "two copies" if n == "hom" else "one copy" if n == "het" else (n or "genotype not given") + + +def finding_rows(summary): + """[finding] from the rows of the summary, each with its level, in the order of the rules.""" + S = summary.get("sections") or {} + out = [] + sf = (_data(S, "clinical").get("acmg_sf") or {}) + sf_genes = {x.get("gene") for x in sf.get("clinvar_hits") or []} + car = _data(S, "carrier") + carrier_genes = {c.get("gene") for c in car.get("carriers") or []} + two_hit = {g if isinstance(g, str) else g.get("gene") for g in (car.get("two_hits") or {}).get("genes") or []} + # 1. ACMG secondary findings: the gene's own reporting rule already said "report". + for r in rr.sf_rows(sf) if sf and not sf.get("unavailable") else []: + out.append(_f("act", "clinical-filter", ("sf", r[0], r[1]), f"Secondary finding in {r[0]}", + f"A change in {r[0]} that the ACMG secondary-findings list reports: {r[3]}.", + gene=r[0], detail=f"{r[0]} {r[1]} {r[2] if r[2] != '.' else ''}".strip(), + evidence=[["Variant", r[1]], ["Genotype", r[2]], ["Why it is reported", r[3]]])) + # 2. Cancer predisposition, CPSR's pathogenic and likely pathogenic rows. + for x in _data(S, "cpsr").get("plp") or []: + out.append(_f("act", "cpsr", ("cpsr", x.get("gene"), x.get("variant")), + f"Cancer predisposition ({x.get('gene')})", + f"CPSR grades this change in {x.get('gene')} as {x.get('final')}: it can raise the risk of some " + f"cancers. It is not a diagnosis of cancer.", gene=x.get("gene"), + detail=f"{x.get('gene')} {x.get('variant')}", + evidence=[["Class", x.get("final")], ["ClinVar", x.get("clinvar")], + ["Call", "low confidence" if x.get("low_confidence") else None]])) + # 3. Two hits in one carrier-list gene: possibly both copies changed. + for g in sorted(x for x in two_hit if x): + out.append(_f("act", "carrier-list", ("two-hits", g), f"Two changes in {g}", + f"Two changes were found in {g}, a gene where it takes two to cause the condition. Whether they " + f"sit on different copies decides whether it matters.", gene=g)) + # 4. ClinVar P/LP hits not already a secondary finding or a carrier row. + for h in (_data(S, "clinvar").get("hits") or [])[:50]: + g = h.get("gene") + if g in sf_genes or g in carrier_genes or g in two_hit: + continue + var = f"{h.get('chrom')}:{h.get('pos')} {h.get('ref')}>{h.get('alt')}" + out.append(_f("review", "clinvar", ("clinvar", g, h.get("chrom"), h.get("pos")), + f"{h.get('significance') or 'Pathogenic'} change in {g}", + f"ClinVar lists this change in {g} as {str(h.get('significance') or '').lower()}; this genome " + f"has {_copies(h.get('genotype'))}. Whether it matters depends on how the gene is inherited: " + f"a genetic counsellor reads it with the rest of this report.", + gene=g, detail=f"{g} {var} {h.get('genotype') or ''}".strip(), + evidence=[["Significance", h.get("significance")], ["ClinVar review", "★" * int(h.get("stars") or 0) or "0 stars"], + ["Genotype", h.get("genotype")], ["Frequency", rr.af_text(h) or "frequency unknown"], + ["Call", clinvar_call(h)]], + viewer=f"{h.get('chrom')}:{h.get('pos')}", af=h.get("af"))) + # 5. Large deletions and duplications AnnotSV ranks class 4 or 5. + for x in _data(S, "annotsv").get("rows") or []: + genes = ", ".join(x.get("genes") or []) or "no gene" + out.append(_f("act" if str(x.get("acmg_class")) == "5" else "review", "structural-variants", + ("sv", x.get("sv")), f"Large {x.get('type') or 'structural change'} over {genes}", + f"A {x.get('type') or 'structural change'} of {abs(int(num(x.get('length')) or 0)):,} bases that " + f"AnnotSV ranks class {x.get('acmg_class')}. Short reads can misplace large changes: confirm it.", + gene=(x.get("genes") or [None])[0], detail=x.get("sv"), + evidence=[["Class", x.get("acmg_class")], ["Callers", rr.annotsv_support_text(x)], + ["Genotype", x.get("gt")]], viewer=str(x.get("sv") or "").replace(" ", "") or None)) + # 6. Repeat expansions outside Stranger's "normal", or in an intermediate zone. + for l in _data(S, "expansions").get("loci") or []: + st = [x for x in (l.get("status") or "").split(",") if x and x != "normal"] + if not st and not l.get("zone"): + continue + full = any("full" in x for x in st) + what = (STR_WORDS.get(st[0], st[0].replace("_", " ")) if st + else f"{l.get('zone_label') or 'intermediate'} range") + out.append(_f("act" if full else "review", "expansions", ("str", l.get("locus")), + f"{l.get('locus')} repeat in the {what}", + f"{l.get('locus')} has {l.get('repeat_count')} repeats: Stranger reads it as \"{what}\". " + f"{'See a clinical geneticist.' if full else 'It may grow when passed on; worth a word with a genetic counsellor.'}", + gene=l.get("locus"), detail=f"{l.get('locus')} {l.get('repeat_count')} repeats", + evidence=[["Repeats", l.get("repeat_count")], ["95% interval", l.get("ci")], + ["Pathologic from", l.get("pathologic_min")], ["Sizing", l.get("sizing")]])) + v = _data(S, "vntyper") + if v.get("status") in ("positive", "possible"): + out.append(_f("act" if v["status"] == "positive" else "review", "vntyper", ("muc1", v["status"]), + f"MUC1 kidney gene: {v['status']}", f"VNtyper reads the MUC1 repeat as {v['status']}; it needs a " + f"clinical confirmation.", gene="MUC1", evidence=[["Confidence", v.get("confidence")]])) + lp = _data(S, "lpa") + if lp.get("flag") == "high": + out.append(_f("review", "lpa", ("lpa",), "Lp(a) likely high", + "The KIV-2 count suggests a high Lp(a); one blood measurement settles it.", gene="LPA", + evidence=[["KIV-2 repeats", lp.get("kiv2_copies")], ["Class", lp.get("class")]])) + for c in _data(S, "chip").get("calls") or []: + out.append(_f("review", "chip", ("chip", c.get("gene"), c.get("protein_change")), + f"A blood clone in {c.get('gene')} (CHIP)", + f"{c.get('gene')} {c.get('protein_change') or ''} is carried by a share of the blood cells (VAF " + f"{c.get('vaf')}). It becomes common with age; most people with it never get a blood cancer.", + gene=c.get("gene"), evidence=[["VAF", c.get("vaf")], ["Reads", f"{c.get('alt_reads')} of {c.get('depth')}"]])) + for c in _data(S, "mosaic").get("calls") or []: + out.append(_f("review", "mosaic-mca", ("mca", c.get("chrom"), c.get("start")), + f"A {c.get('type') or 'change'} on {c.get('chrom')} in some blood cells", + f"MoChA sees a {c.get('type') or 'change'} of {c.get('length_mb')} Mb on {c.get('chrom')} in a share " + f"of the blood cells ({c.get('cell_fraction')}).", + evidence=[["Where", f"{c.get('chrom')}:{c.get('start')}-{c.get('end')}"], + ["Cell fraction", c.get("cell_fraction")]])) + for x in (_data(S, "exomiser").get("genes") or [])[:3]: + out.append(_f("review", "exomiser", ("exomiser", x.get("gene")), f"Symptom ranking: {x.get('gene')} ranks {x.get('rank')}", + "A ranking to bring to a clinical geneticist, not a diagnosis.", gene=x.get("gene"), + evidence=[["Score", x.get("exomiser_score")], ["Inheritance", x.get("moi")]])) + # 7. Carriers: one changed copy in a gene of the carrier list, and the couple screen. + for c in car.get("carriers") or []: + if c.get("gene") in two_hit: + continue + out.append(_f("carrier", "carrier-list", ("carrier", c.get("gene"), c.get("variant")), + f"{c.get('condition') or c.get('gene')} carrier" if c.get("condition") else f"Carrier in {c.get('gene')}", + f"{CARRIER_NOTE} This genome has one changed copy of {c.get('gene')}: a carrier, not affected.", + gene=c.get("gene"), detail=f"{c.get('gene')} {c.get('variant') or ''}".strip(), + evidence=[["Significance", c.get("significance")], ["Stars", c.get("stars")], + ["Inheritance", c.get("inheritance")]])) + cp = _data(S, "couple") + for lab, key in (("Both partners carry", "shared"), ("X-linked", "x_linked")): + for x in cp.get(key) or []: + out.append(_f("carrier", "couple", ("couple", key, x.get("gene")), f"{lab} {x.get('gene')}" + if key == "shared" else f"X-linked finding in {x.get('gene')}", + f"{x.get('condition') or x.get('gene')}: {x.get('risk') or 'see the couple screen'} for each " + f"pregnancy, with {cp.get('partner')}.", gene=x.get("gene"), + evidence=[["Chance for a child", x.get("risk")], ["Partner", cp.get("partner")]])) + for g in _data(S, "paralogs").get("genes") or []: + if g.get("gene") == "SMN1" and str(g.get("copy_number")) == "1" and g.get("status") == "called": + out.append(_f("carrier", "paralogs", ("smn1",), "Spinal muscular atrophy carrier (SMN1)", + "One copy of SMN1 where most people have two: a carrier of spinal muscular atrophy, not " + "affected.", gene="SMN1", evidence=[["SMN1 copies", "1"]])) + # 8. Medicines: each gene whose called phenotype changes function. + for g in _data(S, "cpic").get("genes") or []: + if g.get("status") not in ("non-normal", "ambiguous"): + continue + amb = g["status"] == "ambiguous" + out.append(_f("medicine", "cpic", ("pgx", g.get("gene")), + f"{g.get('gene')}: {g.get('phenotype')}" if not amb else f"{g.get('gene')}: more than one possible result", + f"{g.get('gene')} {g.get('diplotype')} reads as \"{g.get('phenotype')}\" in CPIC's words. The drugs " + f"it bears on are in the CPIC report; tell whoever prescribes.", gene=g.get("gene"), + detail=f"{g.get('gene')} {g.get('diplotype')}", + evidence=[["Diplotype", g.get("diplotype")], ["Phenotype", g.get("phenotype")], ["Status", g.get("status")]])) + # 9. Trust: a problem with the data itself. + q = _data(S, "sample_qc") + if q.get("sex_check") == "mismatch": + out.append(_f("trust", "qc", ("sex",), "Declared and measured sex differ", + "The sex the reads show differs from the one declared: a swapped or mislabelled sample, or a real " + "sex-chromosome difference.", evidence=[["Declared", q.get("declared_sex")], ["From the reads", q.get("inferred_sex")]])) + if q.get("contamination") == "warn": + out.append(_f("trust", "qc", ("contamination",), "Another person's DNA may be mixed in", + rr.freemix_text(q), evidence=[["FREEMIX", q.get("freemix")]])) + if q.get("same_person_as"): + out.append(_f("trust", "qc", ("same-person",), f"The same person as {q['same_person_as']}", + "Two runs of this server hold the same genome.", evidence=[["Matches", q["same_person_as"]]])) + seen, uniq = set(), [] + for x in out: + if x["id"] not in seen: + seen.add(x["id"]) + uniq.append(x) + return uniq + + +def level_of(card, found): + """A card's level: its highest finding's, else data quality for the quality chapter, else review + when the report flags it, else information. A failed or missing card has no level (None).""" + if card.get("state") in ("missing", "failed"): + return None + mine = [f["level"] for f in found if f["card"] == card["id"]] + for lv in LEVELS: + if lv in mine: + return lv + if card.get("chapter") == "quality": + return "trust" + return "review" if card.get("flagged") else "info" + + +def journal(chapters, summary): + """findings, levels and the ten sections of one person's report, from the cards build() made.""" + found = finding_rows(summary) + cards = [] + for ch in chapters: + for c in ch["sections"]: + c["chapter"] = ch["key"] + cards.append(c) + by_id = {c["id"]: c for c in cards} + have = {f["card"] for f in found} + # A card the report flags (its headline or its own rule), for which no row rule found a finding, + # still becomes one: worded by its headline, at review. + for c in cards: + if c.get("flagged") and c["id"] not in have and c.get("state") not in ("missing", "failed"): + lv = "trust" if c["chapter"] == "quality" else "review" + found.append(_f(lv, c["id"], ("card", c["id"]), c["title"], c.get("headline") or + f"The report flags this section: {c['title']}.", evidence=(c.get("facts") or [])[:4])) + for f in found: + c = by_id.get(f["card"]) or {} + f["section"] = LEVEL_SECTION[f["level"]] + f["steps"] = c.get("steps") or [] + f["caveat"] = c.get("caveat") or [] + f["anchor"] = c.get("anchor") or f["card"] + f["plan"] = PLAN[f["level"]] + f["level_words"] = LEVEL_WORDS[f["level"]] + for c in cards: + c["level"] = level_of(c, found) + lv = [f["level"] for f in found if f["card"] == c["id"]] + top = next((x for x in LEVELS if x in lv), None) + home = CHAPTER_SECTION.get(c["chapter"], 10) + if c["chapter"] == "clinical" and top in LEVEL_SECTION: + home = LEVEL_SECTION[top] + c["section"] = home + c["findings"] = [f["id"] for f in found if f["card"] == c["id"]] + for f in found: + f["related"] = [g["id"] for g in found if g["id"] != f["id"] and + ((f["gene"] and g["gene"] == f["gene"]) or g["card"] == f["card"])][:6] + sections = [] + for n, key, title, question in JOURNAL: + sections.append({"n": n, "key": key, "title": title, "question": question, + "findings": [f["id"] for f in found if f["section"] == n], + "cards": [c["id"] for c in cards if c["section"] == n]}) + levels = {lv: sum(1 for f in found if f["level"] == lv) for lv in LEVELS if lv != "info"} + levels["info"] = sum(1 for c in cards if c["level"] == "info") + return found, levels, sections + + +def _names(found, n=3): + t = [f["title"] for f in found[:n]] + more = len(found) - len(t) + s = ", ".join(t[:-1]) + (" and " if len(t) > 1 else "") + t[-1] if t else "" + return s + (f", and {more} more" if more > 0 else "") + + +def _count(n, one, many): + words = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", 7: "seven", 8: "eight", 9: "nine"} + return f"{words.get(n, n)} {one if n == 1 else many}" + + +def lead(found, levels, summary): + """The report's lead paragraph: [[text, bold]] segments, plain sentences that name the findings by + level. It never says a section is fine: with no finding of a level it says what was assessed.""" + S = summary.get("sections") or {} + by = {lv: [f for f in found if f["level"] == lv] for lv in LEVELS} + out = [] + if by["trust"]: + out += [["Read the data-quality section first: ", True], [_names(by["trust"]) + ". ", False]] + if by["act"]: + n = len(by["act"]) + out += [["Of everything this genome was screened for, ", False], + [_count(n, "finding should", "findings should") + " go to a doctor", True], + [": " + _names(by["act"]) + ". ", False]] + elif found or any((S.get(k) or {}).get("state") in ("ok", "stale") for k in ("clinvar", "clinical", "cpsr")): + out += [["No finding in what these steps assessed needs a doctor; ", True], + ["sections 8 and 9 say what they do not see. ", False]] + if by["medicine"]: + genes = sorted({f["gene"] for f in by["medicine"] if f["gene"]}) + out += [[_count(len(by["medicine"]), "medicine result", "medicine results").capitalize(), True], + [f" bear on how drugs work ({', '.join(genes)}): tell whoever prescribes. ", False]] + if by["carrier"]: + out += [[f"A carrier of {_names(by['carrier'], 2)}", True], + [": carriers are not affected; it matters when a partner carries the same gene. ", False]] + if by["review"]: + out += [[_count(len(by["review"]), "result is", "results are").capitalize() + " worth a look", True], + [": " + _names(by["review"]) + ". ", False]] + cov = _data(S, "coverage").get("mean_depth") + if not by["trust"] and isinstance(cov, (int, float)): + out.append([f"Mean depth {cov:.1f}×; the identity and contamination checks raised no flag.", False]) + if out: + out[-1][0] = out[-1][0].rstrip() + return out + + def build(sample, summary, files=()): """results.json of one person from a summary.json; files: the run's paths the report links (for links).""" summary = summary or {} @@ -694,8 +1026,12 @@ def build(sample, summary, files=()): sexc = (S.get("sex_check") or {}).get("data") or {} person = {"sample": sample, "declared_sex": (summary.get("run") or {}).get("declared_sex"), "inferred_sex": sexc.get("inferred_sex"), "karyotype": sexc.get("karyotype")} + found, levels, sections = journal(chapters, summary) return {"schema_version": SCHEMA, "sample": sample, "generated_utc": summary.get("generated_utc"), "person": person, "chapters": chapters, + "findings": found, "levels": levels, "journal": sections, "lead": lead(found, levels, summary), + "level_words": LEVEL_WORDS, "databases": {k: plain(cell(v)) for k, v in (summary.get("databases") or {}).items() + if isinstance(v, (str, int, float))}, "attention": sum(c["attention"] for c in chapters), "not_assessed": [plain(x) for x in summary.get("not_assessed") or []], "not_run": [plain(x) for x in summary.get("not_run") or []], @@ -708,6 +1044,17 @@ def attention_count(summary): return build("", summary)["attention"] +def overview(summary): + """What the home page shows of one person: the attention count, the counts by level, the findings to + act on first (at most four, by level) and the lead as plain text. No file read.""" + r = build("", summary) + order = {lv: i for i, lv in enumerate(LEVELS)} + top = sorted((f for f in r["findings"] if f["level"] != "info"), key=lambda f: order[f["level"]])[:4] + return {"attention": r["attention"], "levels": r["levels"], + "top": [{"id": f["id"], "level": f["level"], "title": f["title"]} for f in top], + "lead": "".join(t for t, _ in r["lead"])} + + def strings(obj): """Every string in a JSON value, with its key path: the wording gate's input.""" if isinstance(obj, dict): diff --git a/bin/owngenome_live.py b/bin/owngenome_live.py index eaa7a993..e2ac55c3 100755 --- a/bin/owngenome_live.py +++ b/bin/owngenome_live.py @@ -89,7 +89,7 @@ RUN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") # validate_sample in scripts/lib/common.sh ASSET_RE = re.compile(r"^(?:plugins/)?[A-Za-z0-9][A-Za-z0-9._-]*$") # The page templates of assets/live: served filled in, never as an asset. -PAGES = ("index.html", "login.html", "home.html", "family.html") +PAGES = ("index.html", "login.html", "home.html", "family.html", "finding.html") TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", ".txt": "text/plain; charset=utf-8", ".tsv": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".pdf": "application/pdf"} @@ -782,6 +782,12 @@ def index_page(self, kind=None): def attention(self, d): """(count of cards worth a look, mtime) from /summary.json alone, cached by its mtime; (None, None) without one. The home page reads no result file beyond that.""" + o, mtime = self.overview(d) + return (o["attention"] if o else None), mtime + + def overview(self, d): + """(live_results.overview of /summary.json, mtime), cached by its mtime; (None, None) without one: + the attention count, the counts by level, the first findings and the lead the home page shows.""" p = os.path.join(d, "summary.json") if d else None if not p or not self.inside(p, os.path.realpath(d)) or not self.inside(p, self.root): return None, None # a summary.json linking out of its run is never read @@ -797,7 +803,7 @@ def attention(self, d): try: import live_results with open(p) as f: - n = live_results.attention_count(json.load(f)) + n = live_results.overview(json.load(f)) except Exception as e: # one bad file must not take the home page down print(f"owngenome_live: {os.path.basename(os.path.dirname(p))}/summary.json not read: {type(e).__name__}", file=sys.stderr) @@ -807,7 +813,8 @@ def attention(self, d): def runs_index(self): """/runs.json: each run's progress, and the families with their members, for the home page. - No result beyond the attention count; no path.""" + No result beyond the attention count, the counts by level, the titles of the first four + findings and the lead; no path.""" runs = self.runs() # Forget the cached results of runs that are gone, so the caches hold only the runs there are. for k in [k for k in list(self.headline_cache) if k not in runs]: @@ -827,8 +834,11 @@ def runs_index(self): running=sum(1 for s in planned if s["state"] == "running"), failed=sum(1 for s in planned if s["state"] == "failed")) # A run known from its messages alone still has its folder under the root once step 24 wrote there. - item["attention"], mtime = self.attention(r["dir"] or os.path.join(self.root, name)) + o, mtime = self.overview(r["dir"] or os.path.join(self.root, name)) + item["attention"] = o["attention"] if o else None item["results"] = mtime is not None + if o: # the Journal home: counts by level, the first findings, the lead + item.update(levels=o["levels"], top=o["top"], lead=o["lead"]) else: item["members"] = r.get("members") or [] out.append(item) diff --git a/bin/owngenome_live_results.py b/bin/owngenome_live_results.py index 1684cf04..3cebff16 100644 --- a/bin/owngenome_live_results.py +++ b/bin/owngenome_live_results.py @@ -2,7 +2,10 @@ GET /runs//results.json bin/live_results.py's build() of the run's summary: the report's seven chapters, one card per section with its - facts, its rows, its caveats and its attention flag + facts, its rows, its caveats and its attention flag, and + the Journal's findings, levels, lead and ten sections + GET /runs//finding// one finding's page (assets/live/finding.html): 404 unless + is a finding of that run's results.json now The summary is the one the server already reads for the step headlines (Live.summary): the run's summary.json when step 24 wrote it after every done @@ -11,6 +14,7 @@ summary.json is never served: no run_file glob is added, and results.json holds what the report prints. Every request writes one access-log row. """ +import html import os import sys @@ -49,3 +53,34 @@ def results(req, m): app.route("GET", r"/runs/(%s)/results\.json" % NAME, results) + def finding(req, m): + """One finding's page. The page itself reads ../../results.json; this only checks that the id is + a finding of the run now, so a typed or stale address is a plain 404, never an empty page.""" + live = req.live + run = live.run(m.group(1)) + fid = m.group(2) + ok = False + if run and run.get("kind") != "family" and live_results_mod().FINDING_ID.match(fid): + try: + d = run["dir"] or os.path.join(live.root, run["name"]) + summary = live.summary(run["name"], d, live.base_state(run)) + ok = bool(summary) and any(f["id"] == fid for f in live_results_mod().build(run["name"], summary)["findings"]) + except Exception as e: # a reader bug is a 404 here, never a 500 + print(f"owngenome_live: finding of {run['name']} not checked: {type(e).__name__}", file=sys.stderr) + if not ok: + app.log("finding", m.group(1), "-", 404) + return req.send_body(404, "text/plain; charset=utf-8", "not found\n") + with open(os.path.join(live.cfg.assets, "finding.html")) as f: + page = f.read() + page = (page.replace("{{assets}}", "/assets").replace("{{run}}", html.escape(run["name"])) + .replace("{{finding}}", html.escape(fid)).replace("{{mode}}", "live")) + app.log("finding", run["name"], fid, 200) + return req.send_body(200, "text/html; charset=utf-8", page) + + app.route("GET", r"/runs/(%s)/finding/([^/]{1,200})/" % NAME, finding) + + +def live_results_mod(): + import live_results + return live_results + diff --git a/tests/test_live_journal.py b/tests/test_live_journal.py new file mode 100644 index 00000000..1b8512ac --- /dev/null +++ b/tests/test_live_journal.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""The Journal layer of a person's results (bin/live_results.py) and the finding page's route +(bin/owngenome_live_results.py). + +Checks: + 1. the level mapping, on a small made-up summary: CPSR's pathogenic row is to act on, a ClinVar hit + outside the carrier list is worth a look, a carrier-list row is for family planning, a CPIC gene + with changed function is for the prescriber, a contamination warning is data quality; each card + takes its highest finding's level, the quality chapter is data quality, a failed card has none; + every finding id is a safe path part (live_results.FINDING_ID) and sits in its level's section; + 2. the lead paragraph: with a finding to act on it says how many go to a doctor and names each one, + a data-quality problem comes first, and with no finding it says none needs a doctor and points at + sections 8 and 9, never an all-clear (live_results.FORBIDDEN); + 3. through the real server: /runs//finding// is behind the login, answers the finding page + with the run and the id filled in and the server's Content-Security-Policy for an id of the run's + findings, and a 404 for an id that is not one, for a path-like id and for a run with no such page. +Each check runs again on a planted mistake and must fail there: 1 with the CPIC rule's level swapped +to review, 2 with the act findings left out of the lead, 3 with the route answering any id. A check +that cannot fail is no check. + +Run: python3 tests/test_live_journal.py +""" +import contextlib +import copy +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import urllib.parse + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "bin")) +sys.path.insert(0, os.path.join(REPO, "tests")) +import collect_summary # noqa: E402 +import live_results # noqa: E402 +import owngenome_live # noqa: E402 + +SAMPLE = "DEMO-001" +PASSWORD = "synthetic test password 9" +FAILS = [] + + +def check(desc, ok, detail=""): + print(("[PASS] " if ok else "[FAIL] ") + desc + ("" if ok else f" -- {str(detail)[:800]}")) + if not ok: + FAILS.append(desc) + + +def control(desc, still_passes): + check(f"control: {desc}", not still_passes, "the check passed on the planted mistake") + + +def ok(data): + return {"state": "ok", "data": data} + + +# A made-up summary: invented genes and positions, one row per rule under test. +SUMMARY = {"sections": { + "cpsr": ok({"plp": [{"gene": "GENEA", "variant": "c.100del", "final": "Pathogenic", "clinvar": "Pathogenic"}]}), + "clinvar": ok({"hits": [{"gene": "GENEB", "chrom": "chr2", "pos": 1000, "ref": "C", "alt": "T", + "significance": "Pathogenic", "stars": 2, "genotype": "het", "af": 0.0001}]}), + "carrier": ok({"carriers": [{"gene": "GENEC", "variant": "c.5A>G", "condition": "Invented condition", + "significance": "Pathogenic", "stars": 2, "inheritance": "AR"}]}), + "cpic": ok({"genes": [{"gene": "CYP2C19", "diplotype": "*2/*2", "phenotype": "Poor Metabolizer", + "status": "non-normal"}, + {"gene": "CYP2C9", "diplotype": "*1/*1", "phenotype": "Normal Metabolizer", "status": "normal"}]}), + "sample_qc": ok({"contamination": "warn", "freemix": 0.05}), + "coverage": ok({"mean_depth": 31.2}), + "lpa": {"state": "failed", "note": "invented failure", "data": {}}, +}} + +WANT = {"cpsr": "act", "clinvar": "review", "carrier": "carrier", "pgx": "medicine", "contamination": "trust"} + + +def level_problems(res): + out = [] + found = res["findings"] + for prefix, lv in WANT.items(): + got = [f["level"] for f in found if f["id"].startswith(prefix)] + if got != [lv]: + out.append(f"{prefix}: {got}, want [{lv}]") + if any(f["id"].startswith("pgx-cyp2c9") for f in found): + out.append("a gene with a normal phenotype became a finding") + for f in found: + if not live_results.FINDING_ID.match(f["id"]): + out.append(f"unsafe id {f['id']!r}") + if f["section"] != live_results.LEVEL_SECTION[f["level"]]: + out.append(f"{f['id']} in section {f['section']}") + cards = {c["id"]: c for ch in res["chapters"] for c in ch["sections"]} + for cid, lv in (("cpsr", "act"), ("cpic", "medicine"), ("carrier-list", "carrier"), ("clinvar", "review"), + ("qc", "trust"), ("lpa", None)): + if cards[cid].get("level") != lv: + out.append(f"card {cid}: {cards[cid].get('level')}, want {lv}") + return out + + +def lead_text(segs): + return "".join(t for t, _ in segs) + + +def lead_problems(res): + out = [] + text = lead_text(res["lead"]) + acts = [f for f in res["findings"] if f["level"] == "act"] + trust = [f for f in res["findings"] if f["level"] == "trust"] + if acts: + n = len(acts) + if f"{'one finding' if n == 1 else f'{n} findings'} should go to a doctor".lower() not in text.lower(): + out.append(f"no count of findings for a doctor: {text[:300]}") + out += [f"lead does not name {f['title']!r}" for f in acts if f["title"] not in text] + elif "needs a doctor" not in text or "sections 8 and 9" not in text: + out.append(f"no-finding lead: {text[:300]}") + if trust and not text.startswith("Read the data-quality section first"): + out.append("a data-quality problem is not first") + if live_results.FORBIDDEN.search(text): + out.append(f"all-clear wording: {text[:300]}") + return out + + +def request(port, method, path, body=None, headers=None): + import http.client + c = http.client.HTTPConnection("127.0.0.1", port, timeout=30) + h = {"Host": f"127.0.0.1:{port}"} + h.update(headers or {}) + c.request(method, path, body=body, headers=h) + r = c.getresponse() + data = r.read() + hdr = {k.lower(): v for k, v in r.getheaders()} + c.close() + return r.status, hdr, data + + +def serve(root, state, pwfile): + app = owngenome_live.App() + with contextlib.redirect_stderr(io.StringIO()): + live, web, ingest = owngenome_live.start(["--root", root, "--state-dir", state, "--port", "0", "--ingest-port", "0", + "--password-file", pwfile, "--quiet"], app) + threading.Thread(target=web.serve_forever, daemon=True).start() + return live, web, ingest + + +def stop(srv): + live, web, ingest = srv + live.stopping = True + web.shutdown() + web.server_close() + ingest.server_close() + + +def route_problems(port, fid): + out = [] + path = f"/runs/{SAMPLE}/finding/{urllib.parse.quote(fid)}/" + code, _, _ = request(port, "GET", path) + if code not in (401, 302, 303): + out.append(f"finding page without a session: {code}") + code, h, _ = request(port, "POST", "/login", urllib.parse.urlencode({"password": PASSWORD, "next": "/"}), + {"Content-Type": "application/x-www-form-urlencoded"}) + m = re.match(r"owngenome_live=([^;]+)", h.get("set-cookie", "")) + if not m: + return out + [f"login {code}"] + ck = {"Cookie": f"owngenome_live={m.group(1)}"} + code, h, body = request(port, "GET", path, headers=ck) + page = body.decode("utf-8", "replace") + if code != 200 or f'data-finding="{fid}"' not in page or f'data-run="{SAMPLE}"' not in page \ + or "{{" in page or "script-src 'self'" not in h.get("content-security-policy", ""): + out.append(f"the finding page: {code} {h.get('content-security-policy')} {page[:200]}") + for bad in ("not-a-finding-of-this-run", "..%2F..%2Fsummary.json", "UPPER"): + code, _, _ = request(port, "GET", f"/runs/{SAMPLE}/finding/{bad}/", headers=ck) + if code != 404: + out.append(f"finding {bad}: {code}, want 404") + code, _, _ = request(port, "GET", f"/runs/NO-SUCH-RUN/finding/{urllib.parse.quote(fid)}/", headers=ck) + if code != 404: + out.append(f"a run that does not exist: {code}") + return out + + +def main(): + work = tempfile.mkdtemp(prefix="test_live_journal.") + srvs = [] + try: + # 1. + res = live_results.build("MADE-UP", copy.deepcopy(SUMMARY)) + bad = level_problems(res) + check("1. each rule gives its level, each card its highest finding's level, each id is safe", not bad, bad) + rows = live_results.finding_rows + + def swapped(summary): + return [dict(f, level="review") if f["id"].startswith("pgx") else f for f in rows(summary)] + live_results.finding_rows = swapped + try: + control("the CPIC rule's level swapped to review", + not level_problems(live_results.build("MADE-UP", copy.deepcopy(SUMMARY)))) + finally: + live_results.finding_rows = rows + + # 2. + bad = lead_problems(res) + check("2. the lead counts and names the findings for a doctor, data quality first", not bad, bad) + calm = copy.deepcopy(SUMMARY) + for k in ("cpsr", "sample_qc", "carrier", "cpic"): + calm["sections"].pop(k) + calm["sections"]["clinvar"] = ok({"hits": []}) + res2 = live_results.build("MADE-UP", calm) + bad = lead_problems(res2) + check("2. with nothing to act on the lead says none needs a doctor and points at sections 8 and 9", not bad, + (bad, lead_text(res2["lead"]))) + planted = dict(res, lead=live_results.lead([f for f in res["findings"] if f["level"] != "act"], res["levels"], + SUMMARY)) + control("the act findings left out of the lead", not lead_problems(planted)) + + # 3. + root = os.path.join(work, "root") + os.makedirs(root) + subprocess.run([sys.executable, os.path.join(REPO, "tests", "demo", "make_demo_sample.py"), "--genome-dir", root, + "--records", "2000"], check=True, stdout=subprocess.DEVNULL) + d = os.path.join(root, SAMPLE) + summary = collect_summary.collect(SAMPLE, d) + with open(os.path.join(d, "summary.json"), "w") as f: + json.dump(summary, f) + found = live_results.build(SAMPLE, summary)["findings"] + check("3. the demo sample has a finding to open", bool(found), found) + pwfile = os.path.join(work, "password") + with open(pwfile, "w") as f: + f.write(owngenome_live.hash_password(PASSWORD) + "\n") + srv = serve(root, os.path.join(work, "state"), pwfile) + srvs.append(srv) + fid = found[0]["id"] if found else "none" + bad = route_problems(srv[1].server_address[1], fid) + check("3. /runs//finding// behind the login, the page for a finding of the run, 404 for any other", + not bad, bad) + real = live_results.FINDING_ID + build = live_results.build + live_results.FINDING_ID = re.compile(r".*") + live_results.build = lambda *a, **k: dict(build(*a, **k), findings=[{"id": x} for x in ( + fid, "not-a-finding-of-this-run", "../../summary.json", "UPPER")]) + try: + control("the route answering any id", not route_problems(srv[1].server_address[1], fid)) + finally: + live_results.FINDING_ID = real + live_results.build = build + finally: + for s in srvs: + stop(s) + shutil.rmtree(work, ignore_errors=True) + if FAILS: + print(f"\n{len(FAILS)} check(s) failed") + return 1 + print("\nAll checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From b522041af8f3428e3cbb47546287a78820671b7a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:07:42 +0100 Subject: [PATCH 12/20] feat(live): lay out home, person, finding, family and run pages as the Journal design Bead: pgp-5bja.31 --- assets/live/core.js | 90 +++- assets/live/family-page.js | 81 ++- assets/live/family.html | 9 +- assets/live/finding.html | 22 + assets/live/finding.js | 237 +++++++++ assets/live/home.html | 11 +- assets/live/home.js | 277 ++++++++--- assets/live/index.html | 86 ++-- assets/live/live.css | 900 ++++++++++++++++++++++------------ assets/live/live.js | 140 +++--- assets/live/login.html | 2 +- assets/live/person.js | 749 ++++++++++++++++++++-------- tests/demo/build_live_demo.py | 14 +- tests/demo/render_pictures.py | 2 +- tests/test_live_demo.py | 92 ++-- 15 files changed, 1990 insertions(+), 722 deletions(-) create mode 100644 assets/live/finding.html create mode 100644 assets/live/finding.js diff --git a/assets/live/core.js b/assets/live/core.js index 64ec443f..a1ea3033 100644 --- a/assets/live/core.js +++ b/assets/live/core.js @@ -1,6 +1,6 @@ -// core.js: what the live site's pages share (home.html, index.html, family.html). Element builders, +// core.js: what the live site's pages share (home.html, index.html, finding.html, family.html). Element builders, // a fetch that sends the session and goes to the login on a 401, the pedigree symbol, dates, the -// caveat block, the facts list and the table. Text only through textContent and createElementNS: +// caveat block, the facts list, the table, and the Journal's level marks and chromosome map. Text only through textContent and createElementNS: // no innerHTML, no inline script, no eval. Everything hangs on window.PGPUI. (function () { 'use strict'; @@ -124,6 +124,90 @@ }); } + // --- the Journal's shared marks: the level glyph and word, and the chromosome map of findings --- + var LEVEL_WORDS = { act: 'Take to a doctor', medicine: 'Tell your prescriber', review: 'Worth a look', + carrier: 'For family planning', trust: 'Data quality', info: 'For information' }; + var LEVEL_SHORT = { act: 'doctor', medicine: 'prescriber', review: 'look', carrier: 'family', trust: 'quality', info: 'info' }; + var LEVEL_ORDER = ['act', 'medicine', 'review', 'carrier', 'trust', 'info']; + // A level is always its glyph and a word, never a colour alone. + function level(lv, words, extra) { + var s = el('span', { 'class': 'lv ' + lv + (extra ? ' ' + extra : ''), 'data-level': lv }); + add(s, el('span', { 'class': 'g', 'aria-hidden': 'true' }), words === undefined ? LEVEL_WORDS[lv] || lv : words); + return s; + } + // GRCh38: [name, length Mb, centromere Mb]. + var CHR = [['1', 248.96, 123.4], ['2', 242.19, 93.9], ['3', 198.3, 90.9], ['4', 190.21, 50], ['5', 181.54, 48.8], + ['6', 170.81, 59.8], ['7', 159.35, 60.1], ['8', 145.14, 45.2], ['9', 138.39, 43], ['10', 133.8, 39.8], + ['11', 135.09, 53.4], ['12', 133.28, 35.5], ['13', 114.36, 17.7], ['14', 107.04, 17.2], ['15', 101.99, 19], + ['16', 90.34, 36.8], ['17', 83.26, 25.1], ['18', 80.37, 18.5], ['19', 58.62, 26.2], ['20', 64.44, 28.1], + ['21', 46.71, 12], ['22', 50.82, 15], ['X', 156.04, 60.6], ['Y', 57.23, 10.4]]; + var LV_COLOUR = { act: '#d03b3b', medicine: '#ec835a', review: '#fab219', carrier: '#9085e9', trust: '#c3c2b7', info: '#c3c2b7' }; + function glyph(g, lv, x, y) { + var c = LV_COLOUR[lv] || LV_COLOUR.info; + if (lv === 'act') { g.appendChild(svg('rect', { x: x - 4, y: y - 4, width: 8, height: 8, fill: c, transform: 'rotate(45 ' + x + ' ' + y + ')' })); } + else if (lv === 'medicine') { g.appendChild(svg('rect', { x: x - 5.5, y: y - 3, width: 11, height: 6, rx: 3, fill: c })); } + else if (lv === 'review') { g.appendChild(svg('path', { d: 'M' + x + ' ' + (y - 5) + 'L' + (x + 5) + ' ' + (y + 4) + 'L' + (x - 5) + ' ' + (y + 4) + 'Z', fill: c })); } + else if (lv === 'carrier') { + g.appendChild(svg('circle', { cx: x - 2.6, cy: y, r: 3.4, fill: 'none', stroke: c, 'stroke-width': 1.6 })); + g.appendChild(svg('circle', { cx: x + 2.6, cy: y, r: 3.4, fill: 'none', stroke: c, 'stroke-width': 1.6 })); + } else { g.appendChild(svg('circle', { cx: x, cy: y, r: 4, fill: 'none', stroke: c, 'stroke-width': 1.5 })); } + } + // "chr16:2087914" or "chr16:2084000-2096000" -> {chr: '16', mb: 2.08}; null for anything else. + function locus(v) { + var m = /^(?:chr)?([0-9]{1,2}|X|Y):([0-9]+)/.exec(String(v || '')); + return m ? { chr: m[1], mb: Number(m[2]) / 1e6 } : null; + } + // The chromosome map: the 24 chromosomes in a row, each finding a glyph on its chromosome, with its + // name beside it and the same words in a title (and in the list under the figure). + function karyogram(marks, opts) { + opts = opts || {}; + var W = opts.width || 680, H = opts.height || 190, top = 22, bottom = 22; + var slot = (W - 56) / CHR.length, scale = (H - top - bottom) / 249; + var s = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': opts.aria || 'Where the findings sit, by chromosome' }); + CHR.forEach(function (c, i) { + var x = i * slot + slot / 2 - 3.5; + s.appendChild(svg('rect', { 'class': 'chr', x: x, y: top, width: 7, height: Math.max(1, c[2] * scale - 1.2), rx: 3.5 })); + s.appendChild(svg('rect', { 'class': 'chr', x: x, y: top + c[2] * scale + 1.2, width: 7, height: Math.max(1, (c[1] - c[2]) * scale - 1.2), rx: 3.5 })); + var t = svg('text', { 'class': 'lab', x: x + 3.5, y: H - 6, 'text-anchor': 'middle' }); + t.textContent = c[0]; + s.appendChild(t); + }); + (marks || []).forEach(function (m) { + var i = -1; + CHR.forEach(function (c, j) { if (c[0] === m.chr) { i = j; } }); + if (i < 0) { return; } + var x = i * slot + slot / 2 + 7, y = top + Math.min(m.mb, CHR[i][1]) * scale; + var g = svg('g', { 'class': 'mk', tabindex: '0' }); + var tt = svg('title', {}); + tt.textContent = (LEVEL_WORDS[m.lv] || '') + ': ' + (m.tip || m.label); + g.appendChild(tt); + g.appendChild(svg('line', { x1: x - 10.5, x2: x - 1, y1: y, y2: y, stroke: LV_COLOUR[m.lv] || LV_COLOUR.info, 'stroke-width': 2 })); + glyph(g, m.lv, x + 4, y); + if (m.label) { + var t = svg('text', { x: x + 11, y: y + 3.5, stroke: '#0e0f10', 'stroke-width': 4, 'paint-order': 'stroke', 'stroke-linejoin': 'round' }); + t.textContent = m.label; + g.appendChild(t); + } + if (m.href) { + g.setAttribute('role', 'link'); + g.addEventListener('click', function () { window.location.href = m.href; }); + g.addEventListener('keydown', function (e) { if (e.key === 'Enter') { window.location.href = m.href; } }); + } + s.appendChild(g); + }); + return s; + } + // A plain time: "14:22" in UTC, "4 Nov" for a day. + var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + function hm(utc) { var d = new Date(utc); return isFinite(d) ? ('0' + d.getUTCHours()).slice(-2) + ':' + ('0' + d.getUTCMinutes()).slice(-2) : ''; } + function dm(utc) { var d = new Date(utc); return isFinite(d) ? d.getUTCDate() + ' ' + MONTHS[d.getUTCMonth()] : ''; } + function hours(a, b) { + var x = Date.parse(a), y = Date.parse(b); + return isFinite(x) && isFinite(y) && y > x ? (y - x) / 3600e3 : null; + } + window.PGPUI = { el: el, svg: svg, add: add, getJSON: getJSON, avatar: avatar, day: day, caveat: caveat, - facts: facts, table: table, tablist: tablist, firstSentence: firstSentence }; + facts: facts, table: table, tablist: tablist, firstSentence: firstSentence, level: level, glyph: glyph, + karyogram: karyogram, locus: locus, LEVEL_WORDS: LEVEL_WORDS, LEVEL_SHORT: LEVEL_SHORT, LEVEL_ORDER: LEVEL_ORDER, + hm: hm, dm: dm, hours: hours }; }()); diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 605cb319..ee893160 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -1,5 +1,6 @@ // family-page.js: a family's page (assets/live/family.html), from family.json -// (bin/owngenome_live_family.py): the pedigree with each sequenced person a link to their page, the +// (bin/owngenome_live_family.py), the Journal design: a report in chapters, with a lead that answers +// "what does it mean for our children?". The pedigree with each sequenced person a link to their page, the // members with their run progress, what steps 38 and 39 planned, each couple's carrier screen in full // (both carry, then X-linked, then one partner), who is related how as a grid (a list of pairs on a // phone), each child's stretches painted by parent, the de novo and compound-het candidates, and the @@ -12,11 +13,57 @@ var el = U.el, add = U.add, root = document.documentElement; var RESULT = { shared: 'Both carry', x_linked: 'X-linked', one_partner: 'One partner' }; + // A chapter: its roman number in brass, its title in the serif. + var CHAPTER = { 'Pedigree': 'I', 'Who is related how': 'II', 'Couple carrier screen': 'III', 'Each child': 'IV', + 'Side by side': 'V', 'Plan and reports': 'App.' }; function section(title, cls) { - var s = el('section', { 'class': 'card ' + (cls || '') }); - add(s, el('h2', {}, title)); + var s = el('section', { 'class': 'card ' + (cls || ''), id: 'ch-' + (cls || 'x') }); + var h = el('h2', {}); + if (CHAPTER[title]) { add(h, el('span', { 'class': 'secnum' }, CHAPTER[title])); } + add(h, title); + add(s, h); return s; } + // Colour follows the person: the members in their order, one colour each, on every family figure. + var PCOL = ['#d95926', '#3987e5', '#199e70', '#c98500', '#9085e9']; + function colourOf(f, sample) { + var i = (f.state.members || []).map(function (m) { return m.sample; }).indexOf(sample); + return i >= 0 ? PCOL[i % PCOL.length] : null; + } + // The four equally likely children of two carriers of one recessive gene: 1 affected, 2 carriers, 1 neither. + function fourChildren(risk) { + var box = el('div', { 'class': 'kids4' }); + var g = el('div', { 'class': 'grid4', role: 'img', 'aria-label': 'Each pregnancy: 1 in 4 affected, 2 in 4 carriers, 1 in 4 neither' }); + add(g, el('span', { 'class': 'aff', title: 'affected' }), el('span', { title: 'carrier' }), el('span', { title: 'carrier' }), + el('span', { 'class': 'non', title: 'neither' })); + add(box, g, add(el('div', { 'class': 'kt' }), el('b', {}, risk || '1 in 4'), ' per pregnancy affected', el('br', {}), + '2 in 4 carriers, not affected; 1 in 4 neither')); + return box; + } + // The family's lead: what the screen, the relationship check and the children say, in plain words. + function lead(f) { + var st = f.state, out = []; + var seq = (st.members || []).filter(function (m) { return m.sequenced; }).length; + out.push([seq + ' genome' + (seq === 1 ? '' : 's') + ' of this family are read together here. ', false]); + (st.couples || []).forEach(function (c) { + if (!c.screened) { out.push([c.a + ' and ' + c.b + ' are not screened as a couple yet. ', false]); return; } + if (c.shared) { out.push([c.a + ' and ' + c.b + ' both carry a change in ' + c.shared + ' gene' + (c.shared > 1 ? 's' : '') + ': 1 in 4 per pregnancy for each. ', true]); } + else { out.push([c.a + ' and ' + c.b + ' share no carrier gene in what the screen sees; ', false], ['it does not see everything. ', false]); } + if (c.x_linked) { out.push([c.x_linked + ' X-linked finding' + (c.x_linked > 1 ? 's' : '') + ' bear on sons. ', true]); } + }); + var flags = (f.relationships || []).filter(function (r) { return r.verdict === 'mismatch' || r.verdict === 'warn'; }).length; + var rels = f.relationships || []; + var checked = rels.filter(function (r) { return r.verdict && r.verdict !== 'not_checked'; }).length; + if (rels.length) { + out.push([flags ? 'A measured relationship differs from the family tree: read chapter II before anything else. ' + : checked === rels.length ? 'The measured relationships match the family tree. ' + : 'Some relationships could not be checked: chapter II says which. ', !!flags]); + } + if ((st.children || []).length) { + out.push([(st.children.length === 1 ? 'One child' : st.children.length + ' children') + ': chapter IV shows what each inherited from whom.', false]); + } + return out; + } function memberUrl(f, s) { return f.runs && f.runs[s] ? f.runs[s].url : null; } // The pedigree drawn from the layout the server gives: a sequenced person with a run here is a link. @@ -39,6 +86,9 @@ var shape = p.sex === 'male' ? U.svg('rect', { x: p.x - h, y: p.y - h, width: L.size, height: L.size, 'class': cls }) : p.sex === 'female' ? U.svg('circle', { cx: p.x, cy: p.y, r: h, 'class': cls }) : U.svg('polygon', { points: [p.x, p.y - h, p.x + h, p.y, p.x, p.y + h, p.x - h, p.y].join(' '), 'class': cls }); + var pc = p.sequenced ? colourOf(f, p.sample) : null; + if (pc) { shape.style.setProperty('stroke', pc); } + if (!p.sequenced) { shape.style.setProperty('stroke-dasharray', '4 3'); } g.appendChild(shape); var t = U.svg('text', { x: p.x, y: p.y + h + 17, 'text-anchor': 'middle', 'class': 'ped-name' }); t.textContent = p.sample; @@ -126,6 +176,13 @@ 'screen does not see. '; if (c.report) { add(p, el('a', { href: c.report }, 'What the screen does not see')); } add(s, p); + rows.filter(function (r) { return r.result === 'shared'; }).forEach(function (r) { + var box = el('div', { 'class': 'mini', 'data-shared': r.gene }); + add(box, el('h3', { 'class': 'h3' }, (r.condition || r.gene) + ' (' + r.gene + ')')); + add(box, el('p', { 'class': 'soft' }, 'Both carry a change in ' + r.gene + '. Carriers are not affected; a child who inherits both changes is.')); + add(box, fourChildren(r.offspring_risk)); + add(s, box); + }); add(s, U.table({ columns: ['Result', 'Gene', 'Condition', 'Chance for a child', c.a, c.b, 'Why'], rows: rows.map(function (r) { var aFirst = r.partner_a === c.a; @@ -366,14 +423,26 @@ root.setAttribute('data-pgp-status', 'ready'); return; } + var cover = el('div', { 'class': 'famcover' }); + var cl = el('div', {}); + add(cl, el('div', { 'class': 'kicker' }, 'Family report \u00b7 in five chapters')); + add(cl, el('h1', { 'class': 'h-display' }, 'Family ' + f.state.name)); + var lp = el('p', { 'class': 'lede dropcap', id: 'family-lead' }); + lead(f).forEach(function (x) { add(lp, x[1] ? el('b', {}, x[0]) : x[0]); }); + add(cl, lp); + var toc = el('ol', { 'class': 'famtoc' }); + [['ch-ped', 'I', 'Pedigree and members'], ['ch-related', 'II', 'Who is related how'], ['ch-couples', 'III', 'Couple carrier screen'], + ['ch-children', 'IV', 'Each child: what they inherited'], ['ch-side-by-side', 'V', 'Side by side']].forEach(function (x) { + add(toc, add(el('li', {}), add(el('a', { href: '#' + x[0] }), el('span', { 'class': 'secnum' }, x[1]), el('span', {}, x[2])))); + }); + add(cover, cl, add(el('aside', {}), el('div', { 'class': 'fig-label' }, 'The chapters'), toc)); + add(page, cover); var top = el('div', { 'class': 'family-top' }); var ped = section('Pedigree', 'ped'); add(ped, pedigree(f), analyses(f)); add(top, ped, members(f)); add(page, top); - var grid = el('div', { 'class': 'family-grid' }); - add(grid, couples(f), related(f)); - add(page, grid, children(f), sideBySide(f)); + add(page, related(f), couples(f), children(f), sideBySide(f)); if ((f.state.plan || []).length || f.state.reports.length) { add(page, more(f)); } add(main, page); root.setAttribute('data-pgp-status', 'ready'); diff --git a/assets/live/family.html b/assets/live/family.html index ebe0d057..b685eca0 100644 --- a/assets/live/family.html +++ b/assets/live/family.html @@ -10,13 +10,14 @@ {{plugins}} -

Loading the family.

+
OwnGenomeGRCh38 no-ALT · a research pipeline, not a clinical test
diff --git a/assets/live/finding.html b/assets/live/finding.html new file mode 100644 index 00000000..e1907433 --- /dev/null +++ b/assets/live/finding.html @@ -0,0 +1,22 @@ + + + + + +{{run}}: one finding: OwnGenome + + + + + +
+
+

Loading the finding.

+
+
OwnGenomeGRCh38 no-ALT · a research pipeline, not a clinical test
+ + diff --git a/assets/live/finding.js b/assets/live/finding.js new file mode 100644 index 00000000..c2c50d34 --- /dev/null +++ b/assets/live/finding.js @@ -0,0 +1,237 @@ +// finding.js: one finding on a page of its own (assets/live/finding.html, the route +// /runs//finding// of bin/owngenome_live_results.py). It answers "what do I do about it?": +// the explanation, the plan under it, then the evidence (the numbers, the frequency on a log strip, the +// reads with a link to the genome viewer), the caveat, the related findings and the family. Everything +// comes from the run's results.json (bin/live_results.py findings); nothing is computed here about +// genetics. A result to act on about a child of the family stays closed until it is opened on purpose. +(function () { + 'use strict'; + var U = window.PGPUI; + if (!U) { return; } + var el = U.el, add = U.add; + var root = document.documentElement; + var BASE = '../../'; + var fid = root.getAttribute('data-finding'); + var replay = root.getAttribute('data-mode') === 'replay'; + var main = document.getElementById('finding'); + var ROMAN = ['i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii']; + + function abs(u) { return !u ? u : (u.charAt(0) === '/' ? u : BASE + u); } + function note(head, body, cls) { + var n = el('div', { 'class': 'note' + (cls ? ' ' + cls : '') }); + add(n, el('span', { 'class': 'nh' }, head), body); + return n; + } + function dl(rows) { + var d = el('dl', {}); + (rows || []).forEach(function (r) { add(d, el('dt', {}, r[0]), el('dd', {}, r[1])); }); + return d; + } + // The frequency on a log strip from 1 in a million to 1: where this change sits among people. + function freqStrip(af) { + var box = el('figure', {}); + add(box, el('div', { 'class': 'fig-label' }, 'How common it is (gnomAD), on a log scale')); + var s = el('div', { 'class': 'fstrip', role: 'img', 'aria-label': af === null ? 'Frequency unknown' : 'Allele frequency ' + af }); + add(s, el('span', { 'class': 'axisline' })); + [[1e-6, '1 in a million'], [1e-4, '1 in 10,000'], [1e-2, '1 in 100'], [1, 'everyone']].forEach(function (t) { + var p = (Math.log10(t[0]) + 6) / 6 * 100; + var k = el('span', { 'class': 'tick' }); k.style.setProperty('left', p + '%'); add(s, k); + var l = el('span', { 'class': 'tl' }, t[1]); l.style.setProperty('left', p + '%'); add(s, l); + }); + if (af !== null && af > 0) { + var m = el('span', { 'class': 'me', title: 'This change: ' + af }); + m.style.setProperty('left', Math.max(0, Math.min(100, (Math.log10(af) + 6) / 6 * 100)) + '%'); + add(s, m); + } + add(box, s); + add(box, el('figcaption', {}, af === null ? 'The frequency of this change is not known here, so it has not been checked: read the caveat.' + : af === 0 ? 'Not seen in gnomAD.' : 'Allele frequency ' + af + '. A change above 1 in 100 is unlikely to be a high-penetrance finding.')); + return box; + } + // A sketch of the reads at the position: what the genome viewer shows in full. + function readsSketch(f) { + var box = el('figure', { 'class': 'reads' }); + add(box, el('div', { 'class': 'fig-label' }, 'The reads at ' + f.viewer)); + var s = U.svg('svg', { viewBox: '0 0 680 90', role: 'img', 'aria-label': 'A sketch of reads over the position' }); + for (var i = 0; i < 9; i++) { + var x = 30 + ((i * 97) % 260), w = 260 + ((i * 53) % 140); + s.appendChild(U.svg('rect', { x: x, y: 6 + i * 9, width: w, height: 5, rx: 1, fill: 'rgba(255,255,255,.14)' })); + } + s.appendChild(U.svg('line', { x1: 340, x2: 340, y1: 0, y2: 90, stroke: '#c8ab78', 'stroke-dasharray': '3 3' })); + box.appendChild(s); + var cap = el('figcaption', {}); + add(cap, 'A sketch, not the data. ', el('a', { href: BASE + 'viewer/#' + encodeURIComponent(f.viewer) }, 'See the real reads in the genome viewer →')); + add(box, cap); + return box; + } + function draw(r, family) { + var f = (r.findings || []).filter(function (x) { return x.id === fid; })[0]; + main.textContent = ''; + if (!f) { + add(main, el('p', { 'class': 'empty' }, 'This finding is not in the results of this run now. The report lists every finding there is.')); + root.setAttribute('data-pgp-status', 'ready'); + return; + } + document.title = f.title + ': ' + r.sample + ': OwnGenome'; + document.getElementById('finding-crumb').textContent = f.title; + var me = family && (family.state.members || []).filter(function (m) { return m.sample === r.sample; })[0]; + var child = !!(me && (me.father || me.mother)); + var sec = (r.journal || []).filter(function (s) { return s.n === f.section; })[0]; + var same = (r.findings || []).filter(function (x) { return x.level === f.level; }); + var order = (r.findings || []).filter(function (x) { return x.level !== 'info'; }); + var next = order[order.indexOf(f) + 1]; + // The rail: back to the report, this page's parts, the next finding of the report. + var rail = el('aside', { 'class': 'toc frail', 'aria-label': 'This finding' }); + var back = el('a', { 'class': 'back', href: BASE + '#f/' + f.id }); + add(back, el('span', { 'aria-hidden': 'true' }, '← '), r.sample + '’s report'); + add(rail, back); + if (sec) { add(rail, el('div', { 'class': 'tsub' }, 'section ' + sec.n + ', ' + sec.title)); } + add(rail, el('div', { 'class': 'group' }, 'This finding')); + var parts = [['what', 'What it is'], ['plan-sec', 'What to do'], ['evidence', 'Evidence']]; + if (f.viewer) { parts.push(['reads', 'The reads']); } + if (family) { parts.push(['family', 'In the family']); } + parts.push(['related', 'Related results'], ['sources', 'Caveat and sources']); + var tol = el('ol', {}); + parts.forEach(function (p, i) { + var a = el('a', { href: '#' + p[0] }); + add(a, el('span', { 'class': 'tn' }, ROMAN[i]), el('span', {}, p[1])); + add(tol, add(el('li', {}), a)); + }); + add(rail, tol); + if (next) { + add(rail, el('div', { 'class': 'group' }, 'Next in ' + r.sample + '’s report')); + add(rail, el('a', { 'class': 'next', href: '../' + encodeURIComponent(next.id) + '/' }, U.LEVEL_WORDS[next.level] + ': ' + next.title + ' →')); + } + var cover = el('div', { 'class': 'fcover', id: 'what' }); + var lv = el('div', { 'class': 'fkick' }); + add(lv, U.level(f.level, f.level_words)); + add(lv, el('span', { 'class': 'dim' }, same.length === 1 ? 'The only finding at this level in ' + r.sample + '’s genome' + : same.length + ' findings at this level in ' + r.sample + '’s genome')); + add(cover, lv); + add(cover, el('h1', { 'class': 'h-display' }, f.title)); + var chips = el('div', { 'class': 'chips' }); + var cs = []; + if (f.gene) { cs.push(f.gene); } + if (f.detail) { cs.push(f.detail); } + if (f.viewer && cs.join(' ').indexOf(f.viewer) < 0) { cs.push(f.viewer); } + cs.forEach(function (c) { add(chips, el('span', { 'class': 'chip mono' }, c)); }); + if (cs.length) { add(cover, chips); } + var main0 = el('div', {}); + add(main0, cover); + var acts = el('aside', { 'class': 'actions' }); + add(acts, el('a', { 'class': 'btn', href: BASE + '#f/' + f.id }, 'Back to the report')); + add(acts, el('a', { 'class': 'btn ghost', href: BASE + 'report.html#' + encodeURIComponent(f.anchor || f.card) }, 'This section in the full report')); + var body = el('div', { 'class': 'jlayout' }); + var col = el('div', {}); + var top = el('div', { 'class': 'row' }); + add(top, main0, acts); + add(col, top); + if (child && f.level === 'act' && !draw.open) { + var v = el('div', { 'class': 'veil' }); + add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), + 'A result to act on in adult life is usually not looked at before the child can choose. It stays closed until you open it.')); + var b = el('button', { type: 'button', 'class': 'btn' }, 'Show the result'); + b.addEventListener('click', function () { draw.open = true; draw(r, family); }); + add(v, b); + add(col, v); + add(body, rail, col); + add(main, body); + root.setAttribute('data-pgp-status', 'ready'); + return; + } + var row1 = el('div', { 'class': 'row' }); + var m1 = el('div', {}); + add(m1, el('p', { 'class': 'lede' }, f.text)); + var box = el('div', { 'class': 'planbox lvl-' + f.level, id: 'plan-sec' }); + add(box, el('h2', { 'class': 'h3' }, 'What to do')); + add(box, el('div', { 'class': 'secq' }, 'In this order. A research result: a certified laboratory confirms anything before it is acted on.')); + var ol = el('ol', { 'class': 'todo', id: 'plan' }); + (f.plan || []).forEach(function (t) { add(ol, el('li', {}, t)); }); + add(box, ol); + add(m1, box); + var a1 = el('aside', {}); + if (f.level === 'carrier' && family) { add(a1, note('Family', add(el('span', {}), 'A carrier is not affected. ', el('a', { href: abs(familyUrl) }, 'The couple screen on the family page →')))); } + add(row1, m1, a1); + add(col, row1); + // The evidence. + var sec2 = el('section', { 'class': 'sec', id: 'evidence' }); + add(sec2, add(el('div', { 'class': 'sechead' }), el('span', { 'class': 'secnum' }, 'iii'), el('h2', { 'class': 'h2' }, 'The evidence'))); + add(sec2, el('div', { 'class': 'secq' }, 'The numbers behind it, as the run measured them.')); + var row2 = el('div', { 'class': 'row' }); + var m2 = el('div', {}); + var facts = U.facts(f.evidence); + if (facts) { add(m2, facts); } else { add(m2, el('p', { 'class': 'soft' }, 'The section in the full report holds the numbers.')); } + if ('af' in f && (f.af !== null || f.card === 'clinvar')) { add(m2, freqStrip(typeof f.af === 'number' ? f.af : null)); } + var a2 = el('aside', {}); + if (f.caveat && f.caveat.length) { + var cv = el('div', {}); + f.caveat.forEach(function (t) { add(cv, el('p', {}, t)); }); + add(a2, note('Caveat', cv, 'caveat-note')); + } + add(row2, m2, a2); + add(sec2, row2); + add(col, sec2); + if (f.viewer) { + var sr = el('section', { 'class': 'sec', id: 'reads' }); + add(sr, add(el('div', { 'class': 'sechead' }), el('span', { 'class': 'secnum' }, 'iv'), el('h2', { 'class': 'h2' }, 'The reads'))); + add(sr, el('div', { 'class': 'secq' }, 'The sequenced fragments over the position, in the genome viewer.')); + add(sr, add(el('div', { 'class': 'row' }), add(el('div', {}), readsSketch(f)), el('aside', {}))); + add(col, sr); + } + // Related findings and the family. + var rel = (f.related || []).map(function (id) { return (r.findings || []).filter(function (x) { return x.id === id; })[0]; }).filter(Boolean); + if (family) { + var sf = el('section', { 'class': 'sec', id: 'family' }); + add(sf, add(el('div', { 'class': 'sechead' }), el('span', { 'class': 'secnum' }, ROMAN[parts.length - 3]), el('h2', { 'class': 'h2' }, 'In the family'))); + var fam = el('p', { 'class': 'soft' }); + add(fam, 'Who else in the family has a genome here: '); + (family.state.members || []).filter(function (m) { return m.sample !== r.sample; }).forEach(function (m, i) { + if (i) { add(fam, ', '); } + var u = family.runs && family.runs[m.sample] && family.runs[m.sample].url; + add(fam, u ? el('a', { href: abs(u) }, m.sample) : el('span', {}, m.sample + ' (no genome)')); + }); + add(fam, '. ', el('a', { href: abs(familyUrl) }, 'The family report →')); + add(sf, fam); + add(col, sf); + } + var sec3 = el('section', { 'class': 'sec', id: 'related' }); + add(sec3, add(el('div', { 'class': 'sechead' }), el('span', { 'class': 'secnum' }, ROMAN[parts.length - 2]), el('h2', { 'class': 'h2' }, 'Related results'))); + add(sec3, el('div', { 'class': 'secq' }, 'Other findings of the same gene or the same step, and the family.')); + var ul = el('ul', { 'class': 'list-findings' }); + rel.forEach(function (x) { + var li = el('li', {}); + add(li, el('a', { 'class': 't', href: '../' + encodeURIComponent(x.id) + '/' }, x.title), U.level(x.level, x.level_words)); + add(li, el('span', { 'class': 'x' }, x.text)); + add(ul, li); + }); + if (!rel.length) { add(ul, add(el('li', {}), el('span', { 'class': 'x' }, 'No other finding of this gene or this step.'))); } + add(sec3, ul); + add(col, sec3); + var sec4 = el('section', { 'class': 'sec', id: 'sources' }); + add(sec4, add(el('div', { 'class': 'sechead' }), el('span', { 'class': 'secnum' }, ROMAN[parts.length - 1]), el('h2', { 'class': 'h2' }, 'Caveat and sources'))); + var src = el('p', { 'class': 'soft' }); + add(src, 'From the run’s ' + f.card + ' section (step ' + (f.steps || []).join(', ') + '). ', + el('a', { href: BASE + 'report.html#' + encodeURIComponent(f.anchor || f.card) }, 'Open it in the full report'), '.'); + add(sec4, src); + add(col, sec4); + add(body, rail, col); + add(main, body); + root.setAttribute('data-pgp-status', 'ready'); + } + var familyUrl = null; + function familyOf(st) { + var fam = st && st.plugins && st.plugins.family; + var f = fam && fam.kind === 'member' && (fam.families || [])[0]; + if (!f) { return Promise.resolve(null); } + familyUrl = f.url; + return U.getJSON(abs(f.url) + 'family.json').then(function (d) { return d && d.state ? d : null; }).catch(function () { return null; }); + } + function start() { + var st = replay ? U.getJSON(BASE + 'frames.json').then(function (d) { var fr = (d && d.frames) || []; return fr.length ? fr[fr.length - 1].state : null; }) + : U.getJSON(BASE + 'state.json'); + Promise.all([U.getJSON(BASE + 'results.json'), st.then(familyOf).catch(function () { return null; })]).then(function (x) { + if (x[0]) { draw(x[0], x[1]); } + }).catch(function () { root.setAttribute('data-pgp-status', 'offline'); }); + } + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } +}()); diff --git a/assets/live/home.html b/assets/live/home.html index 48ef0b22..667fa925 100644 --- a/assets/live/home.html +++ b/assets/live/home.html @@ -3,19 +3,20 @@ -OwnGenome: people +OwnGenome: people and runs -
- OwnGenome - +
+ OwnGenome +
-
+
    {{runs}}
+
OwnGenomeGRCh38 no-ALT · a research pipeline, not a clinical test
diff --git a/assets/live/home.js b/assets/live/home.js index 51d58ca7..4dddee7e 100644 --- a/assets/live/home.js +++ b/assets/live/home.js @@ -1,13 +1,19 @@ -// home.js: the live site's home page (assets/live/home.html): every person, grouped by family, from -// /runs.json (bin/owngenome_live.py runs_index). A card shows the pedigree symbol, the person's place -// in the family, the run's progress and how many cards the report flags; runs still going come first -// in each family. The plain list of runs in the page is what shows without scripts. +// home.js: the live site's home page (assets/live/home.html), the Journal design: "does anyone have +// something to act on, and what is running?" From /runs.json (bin/owngenome_live.py runs_index): a +// headline sentence, the findings that need attention across everyone, the run being written first, +// then each family as a block (a small pedigree and one tile per person, with the person's lead and the +// counts by level), the people in no family, and the log of every run. Colour follows the person: each +// member of a family keeps one colour on this page. The plain list of runs is what shows without scripts. (function () { 'use strict'; var U = window.PGPUI; if (!U) { return; } var el = U.el, add = U.add; var root = document.documentElement; + var PCOL = ['var(--p1)', 'var(--p2)', 'var(--p3)', 'var(--p4)', 'var(--p5)']; + var WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; + function word(n) { return WORDS[n] || String(n); } + function cap(t) { return t.charAt(0).toUpperCase() + t.slice(1); } function role(m, fam) { var kids = fam.members.filter(function (x) { return x.father === m.sample || x.mother === m.sample; }); @@ -18,80 +24,233 @@ return m.partner ? 'partner' : 'member'; } function progressText(r) { - if (!r) { return 'No run on this server'; } - if (r.state === 'finished') { return 'Finished' + (r.finished_utc ? ' ' + U.day(r.finished_utc) : ''); } - if (r.state === 'running') { return 'Running: ' + r.done + ' of ' + r.planned + ' steps'; } - if (!r.planned) { return 'Not started'; } + if (!r) { return 'no genome on this server'; } + if (r.state === 'finished') { return 'finished' + (r.finished_utc ? ' ' + U.dm(r.finished_utc) : ''); } + if (r.state === 'running') { return 'being written: ' + r.done + ' of ' + r.planned + ' steps'; } + if (!r.planned) { return 'not started'; } return r.done + ' of ' + r.planned + ' steps'; } - function lookText(r) { - if (!r || !r.results) { return r && r.done ? 'Results so far on the page' : ''; } - if (r.attention === null || r.attention === undefined) { return 'Results on the page'; } - return r.attention ? r.attention + ' worth a look' : 'Nothing flagged; see the caveats'; + // The counts by level a tile shows: never a bare "nothing": no count means no finding of that level + // in what was assessed, and the tile says the report is the place to read it. + function countsRow(r) { + var row = el('div', { 'class': 'counts' }); + var lv = (r && r.levels) || null; + if (!lv) { add(row, el('span', { 'class': 'dim' }, r && r.done ? 'Results so far on the page' : 'No result yet')); return row; } + [['act', 'doctor'], ['medicine', 'prescriber'], ['review', 'look'], ['carrier', 'family'], ['info', 'info']].forEach(function (x) { + if (lv[x[0]]) { add(row, U.level(x[0], lv[x[0]] + ' ' + x[1])); } + }); + if (!row.childNodes.length) { add(row, el('span', { 'class': 'dim' }, 'No finding in what was assessed; read the caveats')); } + return row; } - function personCard(name, sex, sequenced, place, r) { - var li = el('li', { 'class': 'person-card' + (r ? ' has-run' : '') + (r && r.state === 'running' ? ' running' : '') }); - var ratio = r && r.planned ? r.done / r.planned : null; - var inner = r ? el('a', { href: r.url, 'class': 'person-link' }) : el('div', { 'class': 'person-link' }); - add(inner, U.avatar(sex, sequenced, ratio)); - var txt = el('div', { 'class': 'person-text' }); - add(txt, el('span', { 'class': 'person-name' }, name)); - if (place) { add(txt, el('span', { 'class': 'person-place' }, place)); } - if (sequenced || r) { - if (r && r.planned) { - var bar = el('span', { 'class': 'bar', role: 'img', 'aria-label': r.done + ' of ' + r.planned + ' steps done' }); - var fill = el('span', { 'class': 'bar-fill' }); - fill.style.setProperty('--p', String(ratio)); - add(bar, fill); - add(txt, bar); - } - add(txt, el('span', { 'class': 'person-state' }, progressText(r))); - var lk = lookText(r); - if (lk) { add(txt, el('span', { 'class': 'person-look' + (r && r.attention ? ' attn' : '') }, lk)); } - } else { - add(txt, el('span', { 'class': 'person-state' }, 'Not sequenced')); + function tile(m, fam, r, colour) { + var div = el('div', { 'class': 'ptile person-card' + (r ? ' has-run' : ' norun') + (r && r.state === 'running' ? ' running' : ''), + 'data-sample': m.sample }); + if (colour) { div.style.setProperty('--pcol', colour); } + add(div, el('span', { 'class': 'bar-top' })); + var h = el('h3', {}); + add(h, r ? el('a', { href: r.url, 'class': 'person-link' }, m.sample) : el('span', {}, m.sample)); + add(div, h); + var meta = el('div', { 'class': 'meta' }); + add(meta, (fam ? role(m, fam) + ' · ' : '') + progressText(r)); + if (r && r.failed) { add(meta, ' · ', el('span', { 'class': 'warn' }, r.failed + ' step' + (r.failed > 1 ? 's' : '') + ' not assessed')); } + add(div, meta); + if (r && r.lead) { + // Two sentences: the first often says only that nothing needs a doctor, the second names what differs. + var one = U.firstSentence(r.lead), rest = r.lead.slice(one.length).trim(); + add(div, el('p', { 'class': 'lead' }, rest ? one + ' ' + U.firstSentence(rest) : one)); } - add(inner, txt); - add(li, inner); - return li; + else if (!r) { add(div, el('p', { 'class': 'lead' }, 'In the family tree, with no genome on this server.')); } + add(div, countsRow(r)); + if (r) { add(div, el('a', { 'class': 'read', href: r.url }, 'Read the report →')); } + return div; + } + // A small pedigree: parents on top, children under them; filled when sequenced, dashed when not. + function miniPedigree(fam, colourOf) { + var ms = fam.members, W = 260, H = 130; + var kids = ms.filter(function (m) { return ms.some(function (x) { return x.sample === m.father || x.sample === m.mother; }); }); + var top = ms.filter(function (m) { return kids.indexOf(m) < 0; }); + var s = U.svg('svg', { viewBox: '0 0 ' + W + ' ' + H, width: W, height: H, role: 'img', 'aria-label': 'Pedigree of ' + fam.name }); + var pos = {}; + function row(list, y) { list.forEach(function (m, i) { pos[m.sample] = { x: (i + 1) * W / (list.length + 1), y: y }; }); } + row(top, 34); + row(kids, 100); + kids.forEach(function (k) { + var ps = [k.father, k.mother].filter(function (p) { return pos[p]; }); + if (!ps.length) { return; } + var mx = ps.reduce(function (a, p) { return a + pos[p].x; }, 0) / ps.length; + if (ps.length === 2) { s.appendChild(U.svg('line', { x1: pos[ps[0]].x, x2: pos[ps[1]].x, y1: 34, y2: 34, stroke: '#8f8d86' })); } + s.appendChild(U.svg('line', { x1: mx, x2: mx, y1: 34, y2: 66, stroke: '#8f8d86' })); + s.appendChild(U.svg('line', { x1: mx, x2: pos[k.sample].x, y1: 66, y2: 66, stroke: '#8f8d86' })); + s.appendChild(U.svg('line', { x1: pos[k.sample].x, x2: pos[k.sample].x, y1: 66, y2: 88, stroke: '#8f8d86' })); + }); + ms.forEach(function (m) { + var p = pos[m.sample], c = colourOf[m.sample] || '#8f8d86'; + var attrs = { fill: '#15161a', stroke: m.sequenced ? c : '#8f8d86', 'stroke-width': 2 }; + if (!m.sequenced) { attrs['stroke-dasharray'] = '3 3'; } + var shape = m.sex === 'male' ? U.svg('rect', Object.assign({ x: p.x - 11, y: p.y - 11, width: 22, height: 22 }, attrs)) + : U.svg('circle', Object.assign({ cx: p.x, cy: p.y, r: 11 }, attrs)); + s.appendChild(shape); + var t = U.svg('text', { x: p.x, y: p.y + 26, 'text-anchor': 'middle', fill: '#c3c2b7', 'font-size': 11 }); + t.textContent = m.sample; + s.appendChild(t); + }); + return s; } function rank(r) { return !r ? 3 : r.state === 'running' ? 0 : r.state === 'finished' ? 1 : 2; } + function hoursOf(r) { var h = U.hours(r.started_utc, r.finished_utc); return h === null ? '—' : h.toFixed(1); } function draw(data) { var main = document.getElementById('home'); main.textContent = ''; + var runs = data.runs || [], fams = data.families || []; var byName = {}; - (data.runs || []).forEach(function (r) { byName[r.name] = r; }); + runs.forEach(function (r) { byName[r.name] = r; }); + var colourOf = {}; + fams.forEach(function (f) { f.members.forEach(function (m, i) { if (!colourOf[m.sample]) { colourOf[m.sample] = PCOL[i % PCOL.length]; } }); }); + var going = runs.filter(function (r) { return r.state === 'running'; }); + var done = runs.filter(function (r) { return r.state === 'finished'; }); + var acts = runs.reduce(function (a, r) { return a + ((r.levels && r.levels.act) || 0); }, 0); + // The dateline and the headline sentence. + var dl = el('div', { 'class': 'dateline' }); + var today = new Date(); + add(dl, el('span', { 'class': 'd' }, today.toUTCString().slice(0, 16)), + el('span', {}, runs.length + ' genome' + (runs.length === 1 ? '' : 's') + ' · ' + fams.length + ' famil' + (fams.length === 1 ? 'y' : 'ies') + + ' · ' + going.length + ' report' + (going.length === 1 ? '' : 's') + ' being written')); + add(main, dl); + var hero = el('section', { 'class': 'homehero' }); + var left = el('div', {}); + add(left, el('div', { 'class': 'kicker' }, 'On this server')); + var h1 = el('h1', { 'class': 'h-display' }); + if (!runs.length) { add(h1, 'No genome here yet.'); } + else { + if (going.length) { add(h1, cap(word(going.length)) + ' report' + (going.length > 1 ? 's are ' : ' is '), el('em', {}, 'being written'), '. '); } + add(h1, (going.length ? cap(word(done.length)) + (done.length === 1 ? ' is' : ' are') : cap(word(done.length)) + ' report' + (done.length === 1 ? ' is' : 's are')) + ' finished, and ' + + (acts ? word(acts) + ' finding' + (acts === 1 ? ' should' : 's should') + ' go to a doctor.' : 'no finding in what was assessed needs a doctor.')); + } + add(left, h1); + var sub = fams.map(function (f) { return 'Family ' + f.name + ': ' + f.members.filter(function (m) { return m.sequenced; }).length + ' genomes, read alone and together.'; }); + if (going.length) { sub.push(going.map(function (r) { return r.name; }).join(', ') + ' ' + (going.length > 1 ? 'are' : 'is') + ' still running.'); } + if (sub.length) { add(left, el('p', { 'class': 'sub' }, sub.join(' '))); } + if (!runs.length) { add(left, el('p', { 'class': 'sub' }, 'Start one with scripts/run-all.sh and it shows here.')); } + var right = el('div', {}); + add(right, el('div', { 'class': 'fig-label' }, 'Needs attention, across everyone')); + var ul = el('ul', { 'class': 'attnlist', id: 'attention' }); + var order = { act: 0, medicine: 1, carrier: 2, review: 3, trust: -1 }; + var items = []; + // One line per person and level: the first finding names the line, the rest are counted on it. + runs.forEach(function (r) { + var seen = {}; + (r.top || []).forEach(function (f) { + if (order[f.level] === undefined) { return; } + if (seen[f.level]) { seen[f.level].more += 1; return; } + seen[f.level] = { r: r, f: f, more: 0 }; + items.push(seen[f.level]); + }); + }); + items.sort(function (a, b) { return order[a.f.level] - order[b.f.level]; }); + items.slice(0, 6).forEach(function (x) { + var a = el('a', { href: x.r.url + 'finding/' + encodeURIComponent(x.f.id) + '/' }); + var who = el('span', { 'class': 'who' }); + var pc = el('span', { 'class': 'pc' }, x.r.name); + if (colourOf[x.r.name]) { pc.style.setProperty('--pcol', colourOf[x.r.name]); } + add(who, pc, ' · ' + U.LEVEL_WORDS[x.f.level].toLowerCase()); + add(a, U.level(x.f.level, ''), who, el('span', { 'class': 'what' }, x.f.title + (x.more ? ' and ' + x.more + ' more' : '')), el('span', { 'class': 'arr', 'aria-hidden': 'true' }, '→')); + add(ul, add(el('li', {}), a)); + }); + if (!items.length) { add(ul, add(el('li', {}), el('p', { 'class': 'soft' }, runs.length ? 'No finding to act on, for a prescriber or for family planning in what the finished steps assessed. Each report says what its steps do not see.' : 'Nothing yet.'))); } + add(right, ul); + add(hero, left, right); + add(main, hero); + // Being written: a run in progress comes first. + going.forEach(function (r) { + var head = el('div', { 'class': 'blockhead' }); + add(head, el('h2', {}, 'Being written'), el('span', { 'class': 'dim' }, 'a run in progress comes first'), el('a', { 'class': 'go', href: r.url }, 'Watch it →')); + add(main, head); + var card = el('div', { 'class': 'writing-card', 'data-run': r.name }); + var c1 = el('div', {}); + add(c1, el('div', { 'class': 'kicker' }, 'Being written')); + add(c1, add(el('h3', {}), el('a', { href: r.url }, r.name))); + add(c1, el('div', { 'class': 'meta dim' }, r.started_utc ? 'started ' + U.dm(r.started_utc) + ' ' + U.hm(r.started_utc) : '')); + var big = el('div', { 'class': 'bignum' }, String(r.done)); + add(big, el('small', {}, 'of ' + r.planned + ' steps')); + add(c1, big); + var bar = el('div', { 'class': 'stepbar' }); + [['d', r.done], ['r', r.running], ['f', r.failed]].forEach(function (x) { + if (!x[1]) { return; } + var i = el('i', { 'class': x[0] }); + i.style.setProperty('width', (100 * x[1] / Math.max(1, r.planned)).toFixed(2) + '%'); + add(bar, i); + }); + add(c1, bar, add(el('div', { 'class': 'stepkey' }), el('span', {}, r.done + ' done'), el('span', {}, r.running + ' running'), + el('span', {}, (r.planned - r.done - r.running - r.failed) + ' waiting'))); + var c2 = el('div', {}); + add(c2, el('div', { 'class': 'fig-label' }, 'Found so far')); + var fs = el('ul', { 'class': 'fs' }); + (r.top || []).forEach(function (f) { add(fs, add(el('li', {}), U.level(f.level, ''), ' ', f.title)); }); + if (!fs.childNodes.length) { add(fs, el('li', {}, r.lead ? U.firstSentence(r.lead) : 'No section is written yet: the first ones appear as their steps end.')); } + add(c2, fs); + var c3 = el('div', {}); + add(c3, el('div', { 'class': 'fig-label' }, 'Timing')); + var el2 = U.hours(r.started_utc, new Date().toISOString()); + add(c3, add(el('div', { 'class': 'timing' }), add(el('div', {}), el('span', { 'class': 'k' }, 'Elapsed'), + el('span', { 'class': 'v' }, el2 !== null ? el2.toFixed(1) + ' h' : '—')))); + add(card, c1, c2, c3); + add(main, card); + }); + // The families. var inFamily = {}; - (data.families || []).forEach(function (f) { - var block = el('section', { 'class': 'family-block', 'data-family': f.name }); - var h = el('header', { 'class': 'family-head' }); + fams.forEach(function (f) { + var head = el('div', { 'class': 'blockhead family-block family-head', 'data-family': f.name }); var seq = f.members.filter(function (m) { return m.sequenced; }).length; - var h2 = el('h2', {}); - add(h2, el('a', { href: f.url }, 'Family ' + f.name)); - add(h, h2, el('span', { 'class': 'dim' }, seq + ' of ' + f.members.length + ' sequenced')); - add(h, el('a', { href: f.url, 'class': 'open' }, 'Open the family page')); - add(block, h); - var ul = el('ul', { 'class': 'people' }); + add(head, add(el('h2', {}), el('a', { href: f.url }, 'Family ' + f.name)), + el('span', { 'class': 'dim' }, seq + ' genome' + (seq === 1 ? '' : 's') + (f.members.length > seq ? ', ' + (f.members.length - seq) + ' without one' : '')), + el('a', { 'class': 'go', href: f.url }, 'Family report →')); + add(main, head); + var rowx = el('div', { 'class': 'family-row' }); + var side = el('div', { 'class': 'fam-side' }); + var cols = {}; + f.members.forEach(function (m) { cols[m.sample] = { 'var(--p1)': '#d95926', 'var(--p2)': '#3987e5', 'var(--p3)': '#199e70', 'var(--p4)': '#c98500', 'var(--p5)': '#9085e9' }[colourOf[m.sample]]; }); + add(side, miniPedigree(f, cols)); + add(side, el('p', { 'class': 'soft' }, 'The family report reads the genomes together: who is related how, what the children inherited, and each couple’s carrier screen.')); + add(side, el('a', { href: f.url }, 'Read the family report →')); + var ppl = el('div', { 'class': 'fam-people' }); f.members.slice().sort(function (a, b) { return rank(byName[a.sample]) - rank(byName[b.sample]); }).forEach(function (m) { inFamily[m.sample] = true; - add(ul, personCard(m.sample, m.sex, m.sequenced, role(m, f), byName[m.sample])); + add(ppl, tile(m, f, byName[m.sample], colourOf[m.sample])); }); - add(block, ul); - add(main, block); + add(rowx, side, ppl); + add(main, rowx); }); - var loose = (data.runs || []).filter(function (r) { return !inFamily[r.name]; }) - .sort(function (a, b) { return rank(a) - rank(b) || (a.name < b.name ? -1 : 1); }); + var loose = runs.filter(function (r) { return !inFamily[r.name]; }).sort(function (a, b) { return rank(a) - rank(b) || (a.name < b.name ? -1 : 1); }); if (loose.length) { - var block = el('section', { 'class': 'family-block' }); - add(block, add(el('header', { 'class': 'family-head' }), el('h2', {}, (data.families || []).length ? 'Not in a family' : 'People'))); - var ul = el('ul', { 'class': 'people' }); - loose.forEach(function (r) { add(ul, personCard(r.name, r.declared_sex, true, null, r)); }); - add(block, ul); - add(main, block); + var lh = el('div', { 'class': 'blockhead family-block' }); + add(lh, el('h2', {}, fams.length ? 'Not in a family' : 'People')); + add(main, lh); + var lp = el('div', { 'class': 'fam-people' }); + loose.forEach(function (r) { add(lp, tile({ sample: r.name, sex: r.declared_sex }, null, r, null)); }); + add(main, lp); } - if (!loose.length && !(data.families || []).length) { - add(main, el('p', { 'class': 'empty' }, 'No run under this folder yet. Start one with scripts/run-all.sh and it shows here.')); + // Every run: the log, for whoever runs the server. + if (runs.length) { + var eh = el('div', { 'class': 'blockhead' }); + add(eh, el('h2', {}, 'Every run'), el('span', { 'class': 'dim' }, 'the log, for whoever runs the server')); + add(main, eh); + var t = el('table', { 'class': 'runlog', id: 'runlog' }); + add(t, add(el('thead', {}), add(el('tr', {}), el('th', {}, 'Run'), el('th', {}, 'State'), el('th', { 'class': 'r' }, 'Steps'), + el('th', { 'class': 'r' }, 'Hours'), el('th', {}, 'Finished'), el('th', {}, 'Notes')))); + var tb = el('tbody', {}); + runs.slice().sort(function (a, b) { return rank(a) - rank(b) || (a.name < b.name ? -1 : 1); }).forEach(function (r) { + var pc = el('span', { 'class': 'pc' }, r.name); + if (colourOf[r.name]) { pc.style.setProperty('--pcol', colourOf[r.name]); } + var stc = el('span', { 'class': 'st-' + r.state }, r.state === 'finished' ? '✓ finished' : r.state); + var notes = r.failed ? r.failed + ' step' + (r.failed > 1 ? 's' : '') + ' failed: not assessed' : r.running ? r.running + ' running now' : ''; + add(tb, add(el('tr', {}), add(el('td', {}), add(el('a', { href: r.url }), pc)), add(el('td', {}), stc), + el('td', { 'class': 'r' }, r.done + ' / ' + r.planned), el('td', { 'class': 'r' }, hoursOf(r)), + el('td', {}, r.finished_utc ? U.dm(r.finished_utc) + ', ' + U.hm(r.finished_utc) : r.state === 'running' ? 'running' : '—'), + el('td', {}, notes))); + }); + add(t, tb); + add(main, t); } root.setAttribute('data-pgp-people', String(main.querySelectorAll('.person-card').length)); root.setAttribute('data-pgp-status', 'ready'); diff --git a/assets/live/index.html b/assets/live/index.html index 6f170ec4..f02100f9 100644 --- a/assets/live/index.html +++ b/assets/live/index.html @@ -11,41 +11,71 @@ {{plugins}} -
- OwnGenome - +
+ OwnGenome +
- -
+ +
-
-

{{run}} loading

-

- -

-
-
- -
- -
-
-
-
-
-
- +
+
+
+
+
+
Personal genome report
+

{{run}}

+ +
-
-
+ +
+ + +
+ +
+
+
+
+
–

How the run went

+

One line per step: when it ran, and what it found.

+
+
+
Figure · The run, one line per step
+
+ +

+
+ +
+
+
+
+ +
OwnGenomeGRCh38 no-ALT · a research pipeline, not a clinical test
diff --git a/assets/live/live.css b/assets/live/live.css index 9d3b4126..88151b46 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -1,402 +1,654 @@ -/* live.css: the live site's pages (assets/live): home, a person, a family, the login. - Dark first. No green anywhere: a result that ran is not a finding. --fail marks a failed step, - never a result. A tone is always a word and a symbol, never a colour alone. */ -:root { color-scheme: dark light; - --bg: #14181f; --card: #1d232d; --card2: #232a36; --rule: #2f3847; --text: #e4e7ec; --dim: #9aa3b2; - --info: #7fa6d9; --attn: #e0a64b; --low: #a99ad6; --fail: #c8664f; --focus: #7fa6d9; - --anc1: #6f8fb8; --anc2: #b98a5e; --anc3: #8b7fb5; --anc4: #5f9ea0; --anc5: #a8787f; - --serif: ui-serif, "Iowan Old Style", "Palatino Linotype", Georgia, serif; - --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --mono: ui-monospace, Menlo, monospace; - --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px; --s7: 48px; - --r-card: 8px; --r-chip: 3px; } -@media (prefers-color-scheme: light) { :root { - --bg: #f6f7f9; --card: #fff; --card2: #f0f2f5; --rule: #d5dae1; --text: #1b2029; --dim: #566070; - --info: #2f5f9e; --attn: #8a5a00; --low: #5f4ea0; --fail: #a33a26; --focus: #2f5f9e; } } +/* live.css: the live site's pages (assets/live): home, a person's report, one finding, a family, the login. + The Journal design (docs/design/journal/): each genome reads as a typeset report. Dark only. Serif for + headings, the sans for UI text and inside every chart, tabular figures, mono ids. Brass marks section + numbers, links and "being written", never a level. Green marks data and run states only, never a + health result. A level is always a glyph and a word, never a colour alone. */ +:root { color-scheme: dark; + --page: #0e0f10; --paper: #15161a; --paper-2: #1b1c21; + --hair: rgba(255, 255, 255, .085); --hair-2: rgba(255, 255, 255, .14); + --ink: #f0efec; --ink-2: #c3c2b7; --ink-3: #8f8d86; + --brass: #c8ab78; --brass-dim: rgba(200, 171, 120, .35); + --act: #d03b3b; --med: #ec835a; --rev: #fab219; --car: #9085e9; --good: #0ca30c; + --p1: #d95926; --p2: #3987e5; --p3: #199e70; --p4: #c98500; --p5: #9085e9; + --serif: ui-serif, "New York", "Iowan Old Style", Palatino, Georgia, serif; + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, monospace; + /* the older names the plugins and the family page use */ + --bg: var(--page); --card: var(--paper); --card2: var(--paper-2); --rule: var(--hair-2); --text: var(--ink); + --dim: var(--ink-3); --info: var(--brass); --attn: var(--rev); --low: var(--car); --fail: #ef6a6a; --focus: var(--brass); + --anc1: #3987e5; --anc2: #d95926; --anc3: #199e70; --anc4: #c98500; --anc5: #9085e9; + --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px; --s7: 48px; --r-card: 8px; --r-chip: 999px; } * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; } -body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 var(--sans); font-variant-numeric: tabular-nums; } -a { color: var(--info); text-underline-offset: 2px; } -a:hover { text-decoration-thickness: 2px; } -:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; } -h1, h2, h3 { font-weight: 600; margin: 0; } -.dim, .muted { color: var(--dim); } +html, body { margin: 0; background: var(--page); color: var(--ink); } +body { font: 15px/1.55 var(--sans); -webkit-font-smoothing: antialiased; } +a { color: inherit; text-decoration: underline; text-decoration-color: var(--brass-dim); text-underline-offset: 3px; + text-decoration-thickness: 1px; } +a:hover { text-decoration-color: var(--brass); } +:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px; border-radius: 2px; } +h1, h2, h3, h4 { font-weight: 400; margin: 0; } +p { margin: 0 0 14px; } [hidden] { display: none !important; } -button { font: inherit; background: var(--card2); color: var(--text); border: 1px solid var(--rule); border-radius: 6px; - padding: 5px 12px; cursor: pointer; } -button:hover { border-color: var(--dim); } +.dim, .muted { color: var(--ink-3); } +.soft { color: var(--ink-2); } +.mono { font-family: var(--mono); font-size: .88em; letter-spacing: -.01em; } +.num { font-variant-numeric: tabular-nums; } +button { font: 500 13px/1 var(--sans); color: var(--ink); background: var(--paper-2); border: 1px solid var(--hair-2); + border-radius: 7px; padding: 9px 13px; cursor: pointer; } +button:hover { border-color: var(--brass-dim); } +.btn { display: inline-flex; align-items: center; gap: 8px; font: 500 13px/1 var(--sans); color: var(--ink); background: var(--paper-2); + border: 1px solid var(--hair-2); border-radius: 7px; padding: 9px 13px; text-decoration: none; cursor: pointer; } +.btn:hover { border-color: var(--brass-dim); } +.btn.ghost { background: transparent; } +.toggle { font: 500 12.5px var(--sans); color: var(--ink-2); cursor: pointer; background: none; border: 0; padding: 0; + text-decoration: underline; text-decoration-color: var(--brass-dim); text-underline-offset: 3px; } -/* --- the header every page shares --- */ -.top { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: var(--s4); padding: 10px var(--s5); - background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(6px); border-bottom: 1px solid var(--rule); } -.brand { font-family: var(--serif); font-size: 19px; color: var(--text); text-decoration: none; letter-spacing: .01em; } -.crumbs { display: flex; gap: var(--s2); align-items: baseline; color: var(--dim); min-width: 0; overflow: hidden; +/* --- the masthead every page shares --- */ +.top, .mast { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--hair); background: var(--page); } +.mast-in { max-width: 1320px; margin: 0 auto; padding: 0 40px; height: 58px; display: flex; align-items: center; gap: 28px; } +.brand, .wordmark { font-family: var(--serif); font-size: 23px; letter-spacing: .005em; color: var(--ink); text-decoration: none; white-space: nowrap; } +.brand i, .wordmark i { font-style: italic; color: var(--brass); } +.crumbs { display: flex; gap: 10px; align-items: center; color: var(--ink-3); font-size: 13.5px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.crumbs::before { content: "/"; color: var(--rule); } -.crumbs a { color: var(--dim); } -.crumbs .sep { color: var(--rule); } -.crumbs #run-name { color: var(--text); } -.links { margin-left: auto; display: flex; gap: var(--s4); white-space: nowrap; } +.crumbs a { color: var(--ink-2); text-decoration: none; } +.crumbs a:hover { color: var(--ink); } +.crumbs .sep { color: var(--ink-3); opacity: .6; } +.crumbs #run-name, .crumbs .here { color: var(--ink); } +.links { margin-left: auto; display: flex; gap: 18px; white-space: nowrap; font-size: 13px; color: var(--ink-3); align-items: center; } +.links a { color: var(--ink-2); } .search-slot + .links { margin-left: 0; } .logout { margin: 0 0 0 auto; } -.demo-note { margin: 0 0 0 auto; color: var(--dim); font-size: 13px; text-align: right; } +.logout button { padding: 7px 12px; } +.demo-note { margin: 0 0 0 auto; color: var(--ink-3); font-size: 12.5px; text-align: right; } +.livebadge { display: inline-flex; gap: 7px; align-items: center; font-size: 12.5px; color: var(--ink-3); } +.dot-live { width: 7px; height: 7px; border-radius: 50%; background: var(--brass); display: inline-block; box-shadow: 0 0 0 3px rgba(200, 171, 120, .18); } -/* the search box (plugins/search.js) in the header */ -.search-slot { margin-left: auto; position: relative; width: min(320px, 40vw); } +/* the search box (plugins/search.js) */ +.search-slot { margin-left: auto; position: relative; width: min(270px, 40vw); } .search-slot > section.plugin { background: none; border: 0; padding: 0; margin: 0; } .search-slot h2, .search-slot .search-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } .search-slot form { margin: 0; } -.search-slot input[type=search] { width: 100%; font: inherit; font-size: 14px; color: var(--text); background: var(--card); - border: 1px solid var(--rule); border-radius: 6px; padding: 6px 10px; } -.search-slot input[type=search]:disabled { color: var(--dim); } +.search-slot input[type=search] { width: 100%; font: 13px var(--sans); color: var(--ink); background: var(--paper); + border: 1px solid var(--hair-2); border-radius: 999px; padding: 7px 14px; } +.search-slot input[type=search]::placeholder { color: var(--ink-3); } .search-results { display: none; position: absolute; right: 0; top: calc(100% + 6px); width: min(460px, 92vw); max-height: 70vh; - overflow: auto; background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s3) var(--s4); - box-shadow: 0 12px 32px rgba(0, 0, 0, .35); } + overflow: auto; background: var(--paper); border: 1px solid var(--hair-2); border-radius: 8px; padding: 12px 16px; + box-shadow: 0 12px 32px rgba(0, 0, 0, .45); } .search-slot:focus-within .search-results:not(:empty) { display: block; } .search-results ul { padding-left: 1.1em; margin: 4px 0; } -.search-results h3 { font-size: 15px; } -.search-results .suggestions button { padding: 1px 8px; margin: 2px; } +.search-results h3 { font: 600 13px var(--sans); } +.search-results .suggestions button { padding: 3px 8px; margin: 2px; } -/* --- a person: the head, the strip --- */ -.person-head { max-width: 1120px; margin: 0 auto; padding: var(--s5) var(--s5) var(--s3); } -.person-head h1 { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; font-family: var(--serif); font-size: 30px; - font-weight: 500; line-height: 1.2; } -.person-line { color: var(--dim); margin: var(--s2) 0 var(--s3); } -.strip { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 2px; height: 10px; border-radius: 3px; overflow: hidden; - text-decoration: none; } -.strip .cell { background: var(--rule); } -.strip .cell.st-done { background: var(--info); } -.strip .cell.st-running { background: repeating-linear-gradient(45deg, var(--info) 0 3px, var(--card2) 3px 6px); } -.strip .cell.st-failed { background: var(--fail); } -.strip .cell.st-skipped, .strip .cell.st-not-finished { background: var(--card2); } -.strip-text { color: var(--dim); font-size: 13px; margin: 6px 0 0; } -.badge { font: 600 12px/1 var(--sans); padding: 4px 8px; border-radius: var(--r-chip); border: 1px solid var(--rule); color: var(--dim); - letter-spacing: .02em; } -.badge.run-running { color: var(--info); border-color: var(--info); } -.badge.run-finished { color: var(--text); } +/* --- levels: the one scale (act, medicine, review, carrier, trust, info) --- */ +.lv { display: inline-flex; align-items: center; gap: 7px; font: 600 11.5px/1 var(--sans); letter-spacing: .06em; + text-transform: uppercase; white-space: nowrap; } +.lv .g { width: 10px; height: 10px; flex: none; display: inline-block; } +.lv.act { color: #ef6a6a; } .lv.act .g { background: var(--act); transform: rotate(45deg) scale(.82); } +.lv.medicine { color: var(--med); } .lv.medicine .g { background: var(--med); border-radius: 5px; width: 13px; height: 7px; } +.lv.review { color: var(--rev); } .lv.review .g { width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; + border-bottom: 10px solid var(--rev); } +.lv.carrier { color: #aaa1f2; } .lv.carrier .g { width: 15px; height: 9px; + background: radial-gradient(circle at 4.5px 4.5px, transparent 2.6px, var(--car) 3px, var(--car) 4.4px, transparent 4.6px), + radial-gradient(circle at 10.5px 4.5px, transparent 2.6px, var(--car) 3px, var(--car) 4.4px, transparent 4.6px); } +.lv.info { color: var(--ink-2); } .lv.info .g { border: 1.5px solid var(--ink-2); border-radius: 50%; width: 9px; height: 9px; } +.lv.trust { color: #3fc43f; } .lv.trust .g { position: relative; width: 11px; } +.lv.trust .g::after { content: "\2713"; position: absolute; top: -3px; left: 0; font-size: 12px; color: var(--good); } +.lv.trust.problem { color: #ef6a6a; } .lv.trust.problem .g::after { content: "!"; color: #ef6a6a; font-weight: 700; } +.lv.notrun { color: var(--rev); } .lv.notrun .g::after { content: "\25b3"; font-size: 11px; } +.lv.plain { color: var(--ink-3); } -/* the pedigree symbol, with its ring showing progress */ -.avatar { --p: 0; position: relative; display: inline-grid; place-items: center; width: 34px; height: 34px; flex: none; } -.avatar .glyph { width: 20px; height: 20px; border: 2px solid var(--text); } -.avatar.sex-female .glyph { border-radius: 50%; } -.avatar.sex-unknown .glyph { width: 16px; height: 16px; transform: rotate(45deg); } -.avatar.filled .glyph { background: var(--text); } -.avatar.ring::before { content: ""; position: absolute; inset: 0; border-radius: 50%; - background: conic-gradient(var(--info) calc(var(--p) * 360deg), var(--rule) 0); - -webkit-mask: radial-gradient(circle, transparent 14px, #000 15px); mask: radial-gradient(circle, transparent 14px, #000 15px); } +/* stamps: written, being written, waiting, failed */ +.stamp { font: 600 10px/1 var(--sans); letter-spacing: .12em; text-transform: uppercase; padding: 5px 8px; border-radius: 3px; + border: 1px solid var(--hair-2); color: var(--ink-3); white-space: nowrap; } +.stamp.writing { color: var(--brass); border-color: var(--brass-dim); } +.stamp.written { color: var(--ink-2); } +.stamp.failed { color: var(--rev); border-color: rgba(250, 178, 25, .4); } + +/* --- editorial type --- */ +.kicker + .h-display { margin-top: 14px; } +.kicker { font: 600 11.5px/1.2 var(--sans); letter-spacing: .14em; text-transform: uppercase; color: var(--brass); } +.h-display { font-family: var(--serif); font-weight: 400; font-size: 64px; line-height: 1.02; letter-spacing: -.012em; margin: 0; overflow-wrap: anywhere; } +.h1 { font-family: var(--serif); font-weight: 400; font-size: 34px; line-height: 1.1; letter-spacing: -.01em; } +.h2 { font-family: var(--serif); font-weight: 400; font-size: 30px; line-height: 1.15; letter-spacing: -.005em; } +.h3 { font-family: var(--serif); font-weight: 400; font-size: 22px; line-height: 1.25; } +.lede { font-family: var(--serif); font-size: 21px; line-height: 1.5; color: var(--ink); margin: 0; } +.lede b { font-weight: 600; } +.dropcap::first-letter { font-family: var(--serif); float: left; font-size: 74px; line-height: .86; padding: 6px 10px 0 0; color: var(--brass); } +.body-serif { font-family: var(--serif); font-size: 18px; line-height: 1.6; color: var(--ink-2); } +.body-serif b { color: var(--ink); font-weight: 600; } +.secnum { font-family: var(--serif); font-style: italic; color: var(--brass); font-size: 18px; margin-right: 12px; } +.sechead { display: flex; align-items: baseline; gap: 0; margin: 0 0 6px; } +.sechead .stamp { margin-left: auto; align-self: center; } +.secq { font: 13px var(--sans); color: var(--ink-3); margin: 0 0 22px; } +.fig-label { font: 600 10.5px/1 var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); } +figure { margin: 0; } +figcaption { font: 12.5px/1.5 var(--sans); color: var(--ink-3); margin-top: 10px; } +figcaption b { color: var(--ink-2); font-weight: 600; } +svg text { font-family: var(--sans); font-variant-numeric: tabular-nums; } + +/* margin notes */ +.note { font: 13px/1.5 var(--sans); color: var(--ink-2); border-top: 1px solid var(--hair-2); padding-top: 9px; } +.note .nh { font: 600 10.5px/1 var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); display: block; margin-bottom: 7px; } +.note.caveat-note .nh { color: var(--brass); } +.note + .note { margin-top: 22px; } +.note dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin: 0; } +.note dt { color: var(--ink-3); } +.note dd { margin: 0; color: var(--ink); text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } + +/* --- a person's report --- */ +.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; } +.cover { padding: 54px 0 0; display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 56px; } +.cover-main { display: grid; grid-template-columns: minmax(0, 680px) 260px; gap: 48px; } +.byline { margin-top: 18px; font-size: 13.5px; color: var(--ink-3); display: flex; flex-wrap: wrap; gap: 6px 18px; } +.byline b { color: var(--ink-2); font-weight: 500; } +.actions { display: flex; flex-direction: column; gap: 8px; padding-top: 30px; } +.actions .btn { justify-content: flex-start; } +.jlayout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 56px; margin-top: 40px; padding-bottom: 40px; } +.toc { position: sticky; top: 86px; align-self: start; font-size: 13px; max-height: calc(100vh - 100px); overflow: auto; } +.toc ol { list-style: none; margin: 0; padding: 0; } +.toc li a { display: grid; grid-template-columns: 22px 1fr auto; gap: 4px; padding: 6px 0; text-decoration: none; color: var(--ink-2); + border-top: 1px solid var(--hair); align-items: baseline; } +.toc li a:hover, .toc li a[aria-current=true] { color: var(--ink); } +.toc li a[aria-current=true] .tn { color: var(--brass); } +.toc .tn { font-family: var(--serif); font-style: italic; color: var(--ink-3); } +.toc .tc { font-size: 11px; gap: 4px; } +.toc .tc .g { transform: scale(.85); } +.toc .tc.act .g { transform: rotate(45deg) scale(.7); } +.toc .tsub { grid-column: 2 / -1; font-size: 11.5px; color: var(--ink-3); } +.toc .tsub.writing { color: var(--brass); } +.toc .group { font: 600 10.5px var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); margin: 26px 0 8px; } +.toc .group:first-child { margin-top: 0; } +.toc .people a { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; color: var(--ink-2); text-decoration: none; } +.toc .people a:hover { color: var(--ink); } +.row { display: grid; grid-template-columns: minmax(0, 680px) 260px; gap: 48px; } +.row + .row { margin-top: 34px; } +.row > aside { min-width: 0; } +.row > div, .row > figure { min-width: 0; } +section.sec { padding: 56px 0 8px; border-top: 1px solid var(--hair); margin-top: 56px; scroll-margin-top: 70px; } +#results > section.sec:first-child { margin-top: 0; } +.ledger { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-top: 1px solid var(--hair-2); border-bottom: 1px solid var(--hair-2); margin: 34px 0 0; } +.ledger .lv { font-size: 10.5px; letter-spacing: .05em; gap: 6px; white-space: normal; } +.ledger a { text-decoration: none; padding: 16px 14px 14px 0; border-right: 1px solid var(--hair); display: block; min-width: 0; } +.ledger a + a { padding-left: 16px; } +.ledger a:last-child { border-right: 0; } +.ledger .n { font: 300 38px/1 var(--sans); font-variant-numeric: tabular-nums; display: block; margin: 10px 0 4px; color: var(--ink); } +.ledger .w { font-size: 12.5px; color: var(--ink-3); display: block; overflow-wrap: anywhere; } +.trustline { display: flex; gap: 10px; align-items: center; font-size: 13.5px; color: var(--ink-2); margin-top: 16px; flex-wrap: wrap; } +.trustline .sep { color: var(--ink-3); } -/* --- the layout: the rail and the content --- */ -.layout { max-width: 1120px; margin: 0 auto; padding: 0 var(--s5) var(--s7); display: grid; grid-template-columns: 200px minmax(0, 1fr); - gap: var(--s5); align-items: start; } -.rail { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; padding-top: var(--s3); } -.rail [role=tab] { display: flex; justify-content: space-between; align-items: center; gap: var(--s2); padding: 7px 10px; - border-radius: 6px; color: var(--dim); text-decoration: none; border-left: 3px solid transparent; } -.rail [role=tab]:hover { color: var(--text); background: var(--card); } -.rail [role=tab][aria-selected=true] { color: var(--text); background: var(--card); border-left-color: var(--info); } -.rail [data-tab=progress], .rail [data-tab=leaves] { font-size: 14px; } -.rail [data-tab=leaves] { margin-top: var(--s3); } -.count { font-size: 12px; font-weight: 700; min-width: 22px; text-align: center; padding: 1px 6px; border-radius: 10px; - color: var(--bg); background: var(--attn); } -#content { min-width: 0; } +/* a finding in its section */ +.finding { padding: 0 0 4px; scroll-margin-top: 80px; } +.finding + .finding { border-top: 1px solid var(--hair); padding-top: 26px; margin-top: 30px; } +.finding h3 { margin: 12px 0 6px; } +.finding h3 a { text-decoration: none; } +.finding .gene { font: 13px var(--mono); color: var(--ink-3); overflow-wrap: anywhere; } +.finding.new { box-shadow: -14px 0 0 -10px var(--brass); } +.newband { display: inline-block; font: 600 10.5px var(--sans); letter-spacing: .1em; text-transform: uppercase; color: var(--brass); margin-bottom: 8px; } +.todo { counter-reset: t; list-style: none; padding: 0; margin: 14px 0 0; } +.todo li { counter-increment: t; position: relative; padding: 10px 0 10px 40px; border-top: 1px solid var(--hair); font-size: 15px; color: var(--ink); } +.todo li::before { content: counter(t); position: absolute; left: 0; top: 9px; font: italic 20px var(--serif); color: var(--brass); } +.todo-h { font: 600 10.5px var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); margin-top: 22px; } +.readmore { display: inline-flex; gap: 8px; align-items: center; margin-top: 14px; font-size: 14px; } +.list-findings { list-style: none; margin: 0; padding: 0; } +.list-findings li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; padding: 13px 0; border-top: 1px solid var(--hair); align-items: baseline; } +.list-findings .t { font-family: var(--serif); font-size: 19px; color: var(--ink); text-decoration: none; } +.list-findings .x { grid-column: 1 / -1; font-size: 14px; color: var(--ink-2); } +.list-findings .gene { font: 12.5px var(--mono); color: var(--ink-3); } +.empty-sec { font-family: var(--serif); font-size: 18px; color: var(--ink-2); } +.proof { display: grid; gap: 9px; margin: 6px 0 18px; } +.proof i { display: block; height: 9px; border-radius: 3px; background: rgba(255, 255, 255, .06); } +.proof i:nth-child(2) { width: 82%; } .proof i:nth-child(3) { width: 58%; } +.waitnote { border-left: 2px solid var(--brass-dim); padding: 4px 0 4px 14px; font-size: 13.5px; color: var(--ink-2); } +.notassessed { border: 1px solid rgba(250, 178, 25, .35); border-radius: 8px; padding: 12px 14px; background: rgba(250, 178, 25, .05); font-size: 14px; color: var(--ink-2); margin: 0 0 12px; } +.notassessed b { color: var(--rev); font-weight: 600; } +.veil { border: 1px dashed var(--hair-2); border-radius: 8px; padding: 14px 16px; display: flex; gap: 14px; align-items: center; margin: 14px 0; + background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .015) 0 6px, transparent 6px 12px); } +.veil .vt { font-size: 13.5px; color: var(--ink-2); flex: 1; } +.veil .vt b { color: var(--ink); font-weight: 600; } -.view-head { margin: var(--s3) 0 var(--s4); } -.question { font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.25; } -.view-head p { margin: 4px 0 0; max-width: 75ch; } -.cards { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s4); } -#results, #progress, .view { min-width: 0; } +/* section 8 and 9: assessed, no finding; what it cannot see */ +.nf { list-style: none; padding: 0; margin: 0; } +.nf > li { border-top: 1px solid var(--hair); padding: 13px 0; font-size: 14px; } +.nf > li > details > summary, .nf .nfrow { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 20px; cursor: pointer; list-style: none; } +.nf > li > details > summary::-webkit-details-marker { display: none; } +.nf .nfname { font-weight: 500; color: var(--ink); } +.nf .what { color: var(--ink-2); } +.nf .cav { display: block; color: var(--ink-3); font-size: 12.5px; margin-top: 3px; } +.nf details[open] > summary { margin-bottom: 14px; } +.cols2 { columns: 2; column-gap: 32px; font-size: 14px; color: var(--ink-2); padding-left: 18px; margin: 0; } +.cols2 li { margin-bottom: 8px; break-inside: avoid; } +.sources { width: 100%; border-collapse: collapse; font-size: 13.5px; } +.sources th { text-align: left; font: 600 10.5px/1 var(--sans); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); padding: 0 12px 10px 0; border-bottom: 1px solid var(--hair-2); } +.sources td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--hair); color: var(--ink-2); vertical-align: top; overflow-wrap: anywhere; } +.filebtns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; } -/* --- a card --- */ -.card { min-width: 0; overflow-wrap: anywhere; background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s4) var(--s5); } -.card > h2 { font-size: 15px; margin-bottom: var(--s2); } -.result { position: relative; scroll-margin-top: 80px; } -.result.attn { border-left: 4px solid var(--attn); padding-left: calc(var(--s5) - 3px); } -.result.g-waiting { border-style: dashed; background: transparent; } -.result.g-notrun, .result.g-empty { background: repeating-linear-gradient(45deg, transparent 0 9px, color-mix(in srgb, var(--rule) 35%, transparent) 9px 10px); } -.result.target { outline: 2px solid var(--focus); outline-offset: 3px; } -.card-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3); flex-wrap: wrap; margin-bottom: var(--s2); } -.card-head h3 { font-size: 17px; } -.card-head h3 a { color: var(--text); text-decoration: none; } -.card-head h3 a:hover { text-decoration: underline; } -.chips { display: flex; gap: 6px; flex-wrap: wrap; } -.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-chip); - border: 1px solid var(--rule); color: var(--dim); white-space: nowrap; } +/* --- a card: one section of the report, with its numbers, rows and caveat --- */ +.card { min-width: 0; overflow-wrap: anywhere; } +.result { position: relative; scroll-margin-top: 80px; margin: 26px 0 0; padding-top: 18px; border-top: 1px solid var(--hair); } +.result.target { outline: 1px solid var(--brass); outline-offset: 8px; border-radius: 2px; } +.card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; } +.card-head h3 { font-family: var(--serif); font-size: 21px; } +.card-head h3 a { color: var(--ink); text-decoration: none; } +.chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.chip { display: inline-flex; align-items: center; gap: 6px; font: 12px/1 var(--sans); color: var(--ink-2); border: 1px solid var(--hair-2); + border-radius: 999px; padding: 5px 10px; white-space: nowrap; } .chip .sym { font-size: 10px; } -.chip-attn { color: var(--attn); border-color: var(--attn); } -.chip-info { color: var(--dim); } -.chip-recheck { color: var(--low); border-color: var(--low); } -.state-text { color: var(--dim); margin: 0 0 var(--s2); } -.headline { margin: 0 0 var(--s3); max-width: 75ch; } -.note-stale { color: var(--attn); margin: 0 0 var(--s2); } -.empty { color: var(--dim); max-width: 75ch; } -.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--s2) var(--s5); margin: 0 0 var(--s3); } -.fact { min-width: 0; } -.fact dt { color: var(--dim); font-size: 13px; } -.fact dd { margin: 0; overflow-wrap: anywhere; } -.lines { margin: 0 0 var(--s3); padding-left: 1.1em; max-width: 75ch; color: var(--text); } -.lines li { margin: 2px 0; } -.table-wrap { overflow-x: auto; margin: 0 0 var(--s3); } -table.rows { border-collapse: collapse; width: 100%; font-size: 14px; overflow-wrap: normal; } -table.rows th { text-align: left; color: var(--dim); font-weight: 600; font-size: 13px; padding: 6px 10px; border-bottom: 1px solid var(--rule); } -table.rows th { white-space: nowrap; } -table.rows td { padding: 6px 10px; vertical-align: top; overflow-wrap: break-word; min-width: 4ch; } -table.rows tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--rule) 30%, transparent); } -.more { margin-top: 6px; font-size: 13px; } -.caveat { border-top: 1px solid var(--rule); margin-top: var(--s3); padding-top: var(--s3); font-size: 13px; line-height: 1.55; color: var(--dim); } +.chip-attn { color: var(--rev); border-color: rgba(250, 178, 25, .4); } +.chip-info { color: var(--ink-2); } +.chip-recheck, .chip.brass { color: var(--brass); border-color: var(--brass-dim); } +.state-text { color: var(--ink-3); margin: 0 0 10px; } +.headline { margin: 0 0 12px; color: var(--ink); font-family: var(--serif); font-size: 17px; line-height: 1.5; } +.note-stale { color: var(--rev); margin: 0 0 8px; font-size: 14px; } +.empty { color: var(--ink-2); max-width: 75ch; } +.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); border-top: 1px solid var(--hair-2); margin: 0 0 16px; } +.fact { min-width: 0; padding: 12px 14px 10px 0; border-bottom: 1px solid var(--hair); } +.fact dt { font: 600 10.5px var(--sans); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); } +.fact dd { margin: 6px 0 0; color: var(--ink); font-size: 15px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.lines { margin: 0 0 14px; padding-left: 1.1em; max-width: 75ch; color: var(--ink-2); font-size: 14px; } +.lines li { margin: 3px 0; } +.table-wrap { overflow-x: auto; margin: 0 0 14px; } +table.rows { border-collapse: collapse; width: 100%; font-size: 13.5px; overflow-wrap: normal; } +table.rows th { text-align: left; font: 600 10.5px/1 var(--sans); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); + padding: 0 12px 10px 0; border-bottom: 1px solid var(--hair-2); white-space: nowrap; } +table.rows td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--hair); vertical-align: top; color: var(--ink-2); + overflow-wrap: break-word; min-width: 4ch; font-variant-numeric: tabular-nums; } +.more { margin-top: 8px; } +.caveat { margin-top: 12px; font-size: 13px; line-height: 1.55; color: var(--ink-2); border-top: 1px solid var(--hair-2); padding-top: 9px; } +.caveat::before { content: "Caveat"; display: block; font: 600 10.5px/1 var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--brass); margin-bottom: 7px; } .caveat p { margin: 0 0 6px; max-width: 75ch; } -.caveat-lead::before { content: "\24d8"; margin-right: 6px; color: var(--info); } -.caveat summary { cursor: pointer; color: var(--info); } -.card-links { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4); margin: var(--s3) 0 0; font-size: 14px; } -.behind { margin: 0 0 var(--s3); } -.behind summary { cursor: pointer; color: var(--attn); } -.explore { font-size: 14px; color: var(--dim); } -img.plot { max-width: 100%; height: auto; background: #fff; border-radius: 4px; margin-bottom: var(--s3); } +.caveat summary { cursor: pointer; color: var(--ink-2); text-decoration: underline; text-decoration-color: var(--brass-dim); text-underline-offset: 3px; font-size: 12.5px; } +.card-links { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 12px 0 0; font-size: 13.5px; } +.behind { margin: 0 0 14px; border: 1px dashed var(--hair-2); border-radius: 8px; padding: 12px 14px; + background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .015) 0 6px, transparent 6px 12px); } +.behind summary { cursor: pointer; color: var(--ink); font-size: 13.5px; } +.behind summary::after { content: " Show"; color: var(--brass); text-decoration: underline; } +.behind[open] summary::after { content: ""; } +.explore { font-size: 13.5px; color: var(--ink-3); } +img.plot { max-width: 100%; height: auto; background: #fff; border-radius: 4px; margin-bottom: 14px; } /* widgets (person.js): a track with bands, intervals and ticks placed by --a (start) and --w (width) */ -.widget { margin: 0 0 var(--s4); } -.w-row { margin: 0 0 var(--s3); padding-left: 8px; border-left: 3px solid transparent; } -.w-row.w-attn { border-left-color: var(--attn); } -.w-head { margin: 0 0 4px; display: flex; flex-wrap: wrap; gap: 2px 8px; align-items: baseline; } -.w-name { font-weight: 600; font-family: var(--mono); font-size: 14px; } -.w-label { font-weight: 600; } -.w-val { font-variant-numeric: tabular-nums; } -.w-extra { color: var(--dim); font-size: 13px; } -.w-track { position: relative; height: 22px; border-radius: 4px; background: color-mix(in srgb, var(--rule) 55%, transparent); } +.widget { margin: 0 0 18px; } +.w-row { margin: 0 0 12px; padding: 7px 0 0 8px; border-left: 2px solid transparent; border-top: 1px solid var(--hair); } +.w-row.w-attn { border-left-color: var(--rev); } +.w-head { margin: 0 0 5px; display: flex; flex-wrap: wrap; gap: 2px 10px; align-items: baseline; font-size: 13px; } +.w-name { font: 13px var(--mono); color: var(--ink); } +.w-label { font-weight: 600; color: var(--ink); } +.w-val { font-variant-numeric: tabular-nums; color: var(--ink); } +.w-extra { color: var(--ink-3); font-size: 12.5px; } +.w-track { position: relative; height: 14px; border-radius: 3px; background: rgba(255, 255, 255, .05); } .w-band, .w-ci, .w-tick { position: absolute; left: var(--a); } .w-band { top: 0; bottom: 0; width: var(--w); } -.w-band:first-child { border-radius: 4px 0 0 4px; } -.w-normal { background: color-mix(in srgb, var(--dim) 30%, transparent); } -.w-patho { background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--attn) 55%, transparent) 0 2px, transparent 2px 7px); - border-left: 1px solid var(--attn); } -.w-blind { background: repeating-linear-gradient(-45deg, color-mix(in srgb, var(--dim) 45%, transparent) 0 2px, transparent 2px 7px); - border-right: 1px dashed var(--dim); } -.w-ci { top: 50%; height: 2px; width: var(--w); margin-top: -1px; background: var(--text); } -.w-ci::before, .w-ci::after { content: ""; position: absolute; top: -5px; width: 2px; height: 12px; background: var(--text); } +.w-normal { background: rgba(195, 194, 183, .16); } +.w-patho { background: repeating-linear-gradient(45deg, rgba(250, 178, 25, .45) 0 2px, transparent 2px 7px); border-left: 1px solid var(--rev); } +.w-blind { background: repeating-linear-gradient(-45deg, rgba(143, 141, 134, .4) 0 2px, transparent 2px 7px); border-right: 1px dashed var(--ink-3); } +.w-ci { top: 50%; height: 1.5px; width: var(--w); margin-top: -1px; background: var(--ink-2); } +.w-ci::before, .w-ci::after { content: ""; position: absolute; top: -4px; width: 1.5px; height: 9px; background: var(--ink-2); } .w-ci::before { left: 0; } .w-ci::after { right: 0; } -.w-tick { top: -3px; width: 4px; height: 28px; margin-left: -2px; border-radius: 2px; background: var(--text); } -.w-tick.w-low { background: var(--card); border: 2px solid var(--low); } -.w-tick.w-call { width: 12px; height: 12px; top: 5px; margin-left: -6px; border-radius: 50%; background: var(--attn); } -.w-tick:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } -.w-legend { margin: 4px 0 0; color: var(--dim); font-size: 13px; } -.w-key { font-size: 13px; margin: 0; max-width: 75ch; } -.w-calls { margin: 6px 0 0; padding-left: 1.1em; } -.w-fallback summary { cursor: pointer; color: var(--info); font-size: 13px; margin-top: var(--s2); } -.w-copy-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } -.w-copy-list li { display: grid; grid-template-columns: minmax(7ch, 12ch) auto 1fr; gap: 4px 12px; align-items: center; } +.w-tick { top: 1px; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: var(--ink); } +.w-tick.w-low { background: var(--page); border: 2px solid var(--car); } +.w-tick.w-call { background: var(--rev); } +.w-legend { margin: 5px 0 0; color: var(--ink-3); font-size: 12.5px; } +.w-key { font-size: 12.5px; margin: 0; max-width: 75ch; color: var(--ink-3); } +.w-calls { margin: 6px 0 0; padding-left: 1.1em; font-size: 13.5px; } +.w-fallback summary { cursor: pointer; color: var(--ink-2); font-size: 12.5px; margin-top: 8px; } +.w-copy-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; } +.w-copy-list li { display: grid; grid-template-columns: minmax(7ch, 14ch) auto 1fr; gap: 4px 14px; align-items: center; padding: 7px 0; border-top: 1px solid var(--hair); font-size: 13.5px; } .w-copy-list .w-extra { grid-column: 2 / -1; } -.w-dots { display: inline-flex; gap: 5px; align-items: center; min-height: 14px; } -.w-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--info); } -.w-low .w-dot { background: transparent; border: 2px solid var(--low); } -ol.w-path { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 0; align-items: stretch; } -ol.w-path li { display: flex; flex-direction: column; padding: 6px 10px; border: 1px solid var(--rule); border-radius: 6px; - background: var(--card2); position: relative; margin-right: 18px; } -ol.w-path li:not(:last-child)::after { content: "\203a"; position: absolute; right: -14px; top: 50%; transform: translateY(-50%); - color: var(--dim); font-size: 18px; } -ol.w-path li.w-tip { border-color: var(--info); } -ol.w-path li.w-low { border-style: dashed; border-color: var(--low); } -.w-branch { font-family: var(--mono); font-weight: 600; } -.w-count { color: var(--dim); font-size: 12px; } +.w-dots { display: inline-flex; gap: 5px; align-items: center; min-height: 12px; } +.w-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--ink-2); } +.w-low .w-dot { background: transparent; border: 2px solid var(--car); } +ol.w-path { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 0; align-items: stretch; } +ol.w-path li { display: flex; flex-direction: column; padding: 6px 10px; border: 1px solid var(--hair-2); border-radius: 6px; background: var(--paper); + position: relative; margin-right: 18px; } +ol.w-path li:not(:last-child)::after { content: "\203a"; position: absolute; right: -14px; top: 50%; transform: translateY(-50%); color: var(--ink-3); font-size: 18px; } +ol.w-path li.w-tip { border-color: var(--brass-dim); } +ol.w-path li.w-low { border-style: dashed; border-color: var(--car); } +.w-branch { font-family: var(--mono); font-size: 13px; color: var(--ink); } +.w-count { color: var(--ink-3); font-size: 12px; } -/* charts (plugins/charts.js) inside a card */ +/* the chromosome map of the findings (person.js) and the charts plugin */ +.kary { margin-top: 12px; } +.kary svg { width: 100%; height: auto; display: block; } +.kary .chr { fill: rgba(255, 255, 255, .07); } +.kary .lab { fill: var(--ink-3); font-size: 10px; } +.kary .mk { cursor: pointer; } +.kary .mk text { fill: var(--ink-2); font-size: 10.5px; } .chart-slot:empty { display: none; } -figure.chart { margin: 0 0 var(--s3); } -figure.chart h3 { font-size: 14px; color: var(--dim); margin-bottom: 4px; } +figure.chart { margin: 0 0 14px; } +figure.chart h3 { font: 600 10.5px var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 8px; } figure.chart > .caveat { border: 0; padding: 0; margin: 0 0 6px; } +figure.chart > .caveat::before { content: none; } figure.chart svg { width: 100%; height: auto; max-width: 100%; } -.chart-detail { background: var(--card2); border: 1px solid var(--rule); border-radius: 6px; padding: var(--s3); margin: 0 0 var(--s3); } -.chart-detail h3 { font-size: 15px; } +.chart-detail { background: var(--paper); border: 1px solid var(--hair-2); border-radius: 6px; padding: 12px; margin: 0 0 14px; } +.chart-detail h3 { font: 600 14px var(--sans); } -/* Look first */ -.look-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); counter-reset: none; } -.look-list li { background: var(--card); border: 1px solid var(--rule); border-left: 4px solid var(--attn); border-radius: var(--r-card); - padding: var(--s3) var(--s4); } -.look-list a { display: flex; gap: var(--s3); align-items: baseline; text-decoration: none; } -.look-ch { color: var(--dim); font-size: 13px; min-width: 76px; } -.look-title { font-weight: 600; font-size: 16px; } -.look-head { margin: 4px 0 0; color: var(--dim); max-width: 75ch; } -.look-empty { background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s4); } +/* --- the run being written: hero, timeline, running now, the wire --- */ +.runhero { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 56px; padding: 54px 0 0; } +.bignum { font: 200 92px/1 var(--sans); font-variant-numeric: tabular-nums; color: var(--ink); } +.bignum small { font: 300 22px var(--sans); color: var(--ink-3); margin-left: 8px; } +.stepbar { display: flex; height: 5px; margin: 14px 0 8px; background: rgba(255, 255, 255, .07); border-radius: 3px; overflow: hidden; } +.stepbar i { display: block; height: 100%; } +.stepbar .d { background: var(--ink-2); } .stepbar .r { background: var(--brass); } .stepbar .f { background: #ef6a6a; } +.stepkey { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--ink-3); } +.stepkey i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: 0; } +.timing { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--hair-2); margin-top: 22px; } +.timing div { padding: 12px 12px 0 0; } +.timing div + div { padding-left: 12px; border-left: 1px solid var(--hair); } +.timing .k { font: 600 10.5px var(--sans); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); } +.timing .v { font: 300 26px/1 var(--sans); display: block; margin: 8px 0 6px; font-variant-numeric: tabular-nums; } +.timing .r { font-size: 12px; color: var(--ink-3); } +.strip { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 2px; height: 8px; border-radius: 2px; overflow: hidden; text-decoration: none; margin-top: 18px; } +.strip .cell { background: rgba(255, 255, 255, .08); } +.strip .cell.st-done { background: var(--ink-2); } +.strip .cell.st-running { background: var(--brass); } +.strip .cell.st-failed { background: #ef6a6a; } +.strip-text { color: var(--ink-3); font-size: 12.5px; margin: 8px 0 0; } +.badge { font: 600 10px/1 var(--sans); letter-spacing: .12em; text-transform: uppercase; padding: 5px 8px; border-radius: 3px; border: 1px solid var(--hair-2); color: var(--ink-3); vertical-align: middle; } +.badge.run-running { color: var(--brass); border-color: var(--brass-dim); } -/* --- the Progress tab: the step map (live.js) --- */ -.progress-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s4); padding-top: var(--s3); } -.map-wrap { max-height: 70vh; } -.map-wrap { overflow: auto; min-width: 0; background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s3); } -.side { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--s3); align-items: start; } -.side > * { min-width: 0; } -#plugin-panels { display: contents; } -.legend { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; color: var(--dim); font-size: 12px; } -.legend .swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 4px; vertical-align: -2px; border: 1px solid var(--rule); } -.edges line { stroke: var(--rule); stroke-width: 1.2; } +.runsec { padding: 0; border: 0; margin: 0; } +.runsec .progress-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 40px; } +.map-wrap { min-width: 0; background: var(--paper); border: 1px solid var(--hair); border-radius: 10px; padding: 18px 20px; overflow: auto; max-height: 80vh; } +.map-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; } +.legend { display: flex; flex-wrap: wrap; gap: 12px; color: var(--ink-3); font-size: 11.5px; } +.legend .swatch { display: inline-block; width: 12px; height: 4px; border-radius: 2px; margin-right: 6px; vertical-align: 2px; } +#map svg { display: block; } .box { cursor: pointer; } -.box rect { stroke: var(--rule); stroke-width: 1; } -.box text { fill: var(--text); font-size: 12px; pointer-events: none; } -.box.selected rect { stroke: var(--focus); stroke-width: 2; } +.box .lbl { fill: var(--ink-3); font-size: 11px; } +.box rect.bar { fill: rgba(255, 255, 255, .09); } +.box rect.hit { fill: transparent; } +.box:hover .lbl, .box.selected .lbl { fill: var(--ink); } .box:focus { outline: none; } -.box:focus-visible rect { stroke: var(--focus); stroke-width: 3; stroke-dasharray: 4 2; } -.s-waiting rect, .s-waiting .swatch { fill: var(--card2); background: var(--card2); } -.s-running rect, .s-running .swatch { fill: #3a6aa6; background: #3a6aa6; } -.s-done rect, .s-done .swatch { fill: #2b3a4f; background: #2b3a4f; } -.s-failed rect, .s-failed .swatch { fill: var(--fail); background: var(--fail); } -.s-skipped rect, .s-skipped .swatch { fill: #2b2838; background: #2b2838; } -.s-notfinished rect, .s-notfinished .swatch { fill: #4a3c22; background: #4a3c22; } -.s-notplanned rect, .s-notplanned .swatch { fill: var(--bg); background: var(--bg); } -.s-skipped text, .s-notplanned text { fill: var(--dim); } -/* Light theme: pale state fills under the dark label text, so every step name stays readable. */ -@media (prefers-color-scheme: light) { - .s-running rect, .s-running .swatch { fill: #b9d0ef; background: #b9d0ef; } - .s-done rect, .s-done .swatch { fill: #d3dbe6; background: #d3dbe6; } - .s-skipped rect, .s-skipped .swatch { fill: #e6e3ef; background: #e6e3ef; } - .s-notfinished rect, .s-notfinished .swatch { fill: #f1e0bd; background: #f1e0bd; } - .s-failed text { fill: #fff; } -} -.s-running rect { animation: pulse 2s ease-in-out infinite; } -@keyframes pulse { 50% { opacity: .7; } } -.recheck-mark { fill: var(--low); } -dl { margin: 0 0 8px; } -#detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; } -#detail dt { color: var(--dim); } +.box:focus-visible rect.hit { stroke: var(--brass); stroke-width: 1.5; } +.s-done rect.bar, .s-done .swatch { fill: #9a988f; background: #9a988f; } +.s-running rect.bar, .s-running .swatch { fill: var(--brass); background: var(--brass); } +.s-running .lbl { fill: var(--ink); font-weight: 600; } +.s-failed rect.bar, .s-failed .swatch { fill: #ef6a6a; background: #ef6a6a; } +.s-waiting rect.bar, .s-waiting .swatch { fill: transparent; stroke: var(--hair-2); background: transparent; border: 1px solid var(--hair-2); } +.s-skipped rect.bar, .s-skipped .swatch { fill: transparent; stroke: var(--ink-3); stroke-dasharray: 3 2; background: transparent; border: 1px dashed var(--ink-3); } +.s-notfinished rect.bar, .s-notfinished .swatch { fill: #6b5a33; background: #6b5a33; } +.s-notplanned rect.bar, .s-notplanned .swatch { fill: transparent; background: transparent; } +.s-skipped .lbl, .s-notplanned .lbl { fill: rgba(143, 141, 134, .7); } +.box rect.usual { fill: none; stroke: var(--brass-dim); } +.axis text { fill: var(--ink-3); font-size: 10.5px; } +.axis line { stroke: var(--hair); } +.nowline { stroke: var(--ink-2); stroke-width: 1; } +.nowlab { fill: var(--ink); font-size: 10.5px; font-weight: 600; } +.recheck-mark { fill: var(--car); } +.side { display: grid; gap: 26px; align-content: start; } +.side > * { min-width: 0; } +.side .panel, .side > section { border-top: 1px solid var(--hair-2); padding-top: 10px; } +.side h2, .side h3 { font: 600 10.5px var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 10px; } +#plugin-panels { display: contents; } +#plugin-panels section { font-size: 13px; color: var(--ink-2); } +#detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; font-size: 13px; } +#detail dt { color: var(--ink-3); } #detail dd { margin: 0; } -table.tasks { border-collapse: collapse; width: 100%; font-size: 12px; margin-bottom: 8px; } -table.tasks th, table.tasks td { border-bottom: 1px solid var(--rule); padding: 2px 4px; text-align: right; } +#detail h2 { color: var(--ink); text-transform: none; letter-spacing: 0; font: 400 18px var(--serif); } +table.tasks { border-collapse: collapse; width: 100%; font-size: 12px; margin-bottom: 8px; font-variant-numeric: tabular-nums; } +table.tasks th, table.tasks td { border-bottom: 1px solid var(--hair); padding: 3px 4px; text-align: right; color: var(--ink-2); } table.tasks th:first-child, table.tasks td:first-child { text-align: left; } -.found { list-style: none; margin: 0; padding: 0; } -.found li { padding: 6px 0; border-bottom: 1px solid var(--rule); cursor: pointer; } -.found li[role=button]:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } -.found .step { display: inline-block; min-width: 30px; color: var(--dim); } -.tone-attention { border-left: 3px solid var(--attn); padding-left: 6px; } -.tone-info { border-left: 3px solid var(--rule); padding-left: 6px; } +.found { list-style: none; margin: 0; padding: 0; font-size: 13px; } +.found li { padding: 7px 0; border-top: 1px solid var(--hair); cursor: pointer; color: var(--ink-2); } +.found .step { display: inline-block; min-width: 32px; color: var(--ink-3); font-variant-numeric: tabular-nums; } +.tone-attention::before { content: "\25b2 "; color: var(--rev); font-size: 10px; } +.running-now { list-style: none; margin: 0; padding: 0; } +.running-now li { padding: 9px 0; border-top: 1px solid var(--hair); font-size: 13.5px; } +.running-now .rn { display: flex; justify-content: space-between; gap: 10px; color: var(--ink); } +.running-now .rn span:last-child { color: var(--ink-3); font-size: 12px; font-variant-numeric: tabular-nums; } +.running-now .pb { height: 3px; background: rgba(255, 255, 255, .07); margin-top: 7px; border-radius: 2px; overflow: hidden; } +.running-now .pb i { display: block; height: 100%; width: var(--p); background: var(--brass); } .plugin ul.plain { padding-left: 1.1em; } -.replay { display: flex; gap: 10px; align-items: center; padding: 8px var(--s5); border-bottom: 1px solid var(--rule); max-width: 1120px; margin: 0 auto; } -.replay input[type=range] { flex: 1; accent-color: var(--info); } -#replay-time { color: var(--dim); font-size: 13px; } +.replay { display: flex; gap: 12px; align-items: center; padding: 10px 40px; border-bottom: 1px solid var(--hair); max-width: 1320px; margin: 0 auto; font-size: 13px; } +.replay input[type=range] { flex: 1; accent-color: var(--brass); } +#replay-time { color: var(--ink-3); font-variant-numeric: tabular-nums; } .family-table { border-collapse: collapse; width: 100%; font-size: 13px; } -.family-table td, .family-table th { border-bottom: 1px solid var(--rule); padding: 3px 6px; text-align: left; } +.family-table td, .family-table th { border-bottom: 1px solid var(--hair); padding: 4px 6px; text-align: left; } -/* --- home: people by family --- */ -.home, .family { max-width: 1120px; margin: 0 auto; padding: var(--s5) var(--s5) var(--s7); } -.family-block { margin-bottom: var(--s6); } -.family-head { position: sticky; top: 52px; z-index: 5; display: flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap; - padding: var(--s2) 0; background: var(--bg); border-bottom: 1px solid var(--rule); margin-bottom: var(--s3); } -.family-head h2 { font-family: var(--serif); font-size: 24px; font-weight: 500; } -.family-head h2 a { color: var(--text); text-decoration: none; } -.family-head .open { margin-left: auto; font-size: 14px; } -.people { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--s3); } -.person-card .person-link { display: flex; gap: var(--s3); align-items: flex-start; height: 100%; padding: var(--s3) var(--s4); - background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); color: var(--text); text-decoration: none; } -.person-card:not(.has-run) .person-link { background: transparent; border-style: dashed; color: var(--dim); } -.person-card a.person-link:hover { border-color: var(--dim); } -.person-card.running .person-link { border-color: color-mix(in srgb, var(--info) 60%, var(--rule)); } +/* the pedigree symbol, with its ring showing progress */ +.avatar { --p: 0; position: relative; display: inline-grid; place-items: center; width: 30px; height: 30px; flex: none; } +.avatar .glyph { width: 16px; height: 16px; border: 1.5px solid var(--ink-2); } +.avatar.sex-female .glyph { border-radius: 50%; } +.avatar.sex-unknown .glyph { width: 13px; height: 13px; transform: rotate(45deg); } +.avatar.filled .glyph { background: var(--paper-2); } +.avatar.ring::before { content: ""; position: absolute; inset: 0; border-radius: 50%; + background: conic-gradient(var(--brass) calc(var(--p) * 360deg), var(--hair-2) 0); + -webkit-mask: radial-gradient(circle, transparent 12px, #000 13px); mask: radial-gradient(circle, transparent 12px, #000 13px); } + +/* --- one finding (finding.html) --- */ +.fpage .jlayout { margin-top: 0; padding-top: 50px; } +.frail { padding-top: 4px; } +.frail .back { color: var(--ink); text-decoration: none; font-size: 13.5px; } +.frail .back span { color: var(--brass); } +.frail .next { color: var(--ink-2); font-size: 12.5px; } +.fkick { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; font-size: 13px; margin-bottom: 18px; } +.fcover .h-display { font-size: 56px; } +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; } +.fpage .lede { margin-top: 10px; } +.planbox { border: 1px solid var(--hair-2); border-left: 3px solid var(--brass); border-radius: 8px; background: var(--paper); padding: 22px 26px 12px; margin-top: 30px; } +.planbox.lvl-act { border-left-color: var(--act); } +.planbox.lvl-medicine { border-left-color: var(--med); } +.planbox.lvl-review { border-left-color: var(--rev); } +.planbox.lvl-carrier { border-left-color: var(--car); } +.planbox .secq { margin: 6px 0 4px; } +.fstrip { position: relative; height: 34px; margin-top: 12px; } +.fstrip .axisline { position: absolute; left: 0; right: 0; top: 16px; height: 1px; background: var(--hair-2); } +.fstrip .tick { position: absolute; top: 12px; width: 1px; height: 9px; background: var(--ink-3); } +.fstrip .tl { position: absolute; top: 22px; font-size: 10.5px; color: var(--ink-3); transform: translateX(-50%); } +.fstrip .me { position: absolute; top: 9px; width: 14px; height: 14px; margin-left: -7px; border-radius: 50%; background: var(--ink); } +.reads { margin-top: 12px; } +.reads svg { width: 100%; height: auto; } + +/* --- home: people and runs --- */ +.home { max-width: 1320px; margin: 0 auto; padding: 0 40px 40px; } +.dateline { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 34px 0 14px; border-bottom: 1px solid var(--hair-2); + font-size: 12.5px; color: var(--ink-3); } +.dateline .d { font: italic 15px var(--serif); color: var(--ink-2); } +.homehero { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 80px; padding: 40px 0 20px; } +.homehero .h-display { font-size: 56px; line-height: 1.08; margin-top: 14px; } +.homehero .h-display em { color: var(--brass); } +.homehero .sub { font-family: var(--serif); font-size: 19px; line-height: 1.5; color: var(--ink-2); max-width: 600px; margin-top: 22px; } +.attnlist { list-style: none; margin: 0; padding: 0; } +.attnlist li { border-top: 1px solid var(--hair-2); } +.attnlist a { display: grid; grid-template-columns: 22px minmax(0, 1fr) 16px; gap: 6px 10px; padding: 16px 0; text-decoration: none; align-items: start; } +.attnlist .who { font-size: 12.5px; color: var(--ink-3); } +.attnlist .what { grid-column: 2; font: 18px/1.35 var(--serif); color: var(--ink); } +.attnlist .arr { grid-column: 3; grid-row: 1; color: var(--ink-3); } +.attnlist .lv { grid-row: 1 / span 2; margin-top: 2px; } +.blockhead { display: flex; align-items: baseline; gap: 16px; margin: 64px 0 18px; flex-wrap: wrap; } +.blockhead h2 { font: 400 30px/1.1 var(--serif); } +.blockhead h2 a { text-decoration: none; } +.blockhead .dim { font-size: 13px; } +.blockhead .go { margin-left: auto; font-size: 13px; } +.writing-card { display: grid; grid-template-columns: 300px minmax(0, 1fr) 280px; border: 1px solid var(--hair-2); border-radius: 12px; background: var(--paper); } +.writing-card > div { padding: 22px 24px; min-width: 0; } +.writing-card > div + div { border-left: 1px solid var(--hair); } +.writing-card h3 { font: 400 30px var(--serif); margin: 10px 0 6px; } +.writing-card h3 a { text-decoration: none; } +.writing-card .fs { list-style: none; margin: 0; padding: 0; } +.writing-card .fs li { padding: 10px 0; border-bottom: 1px solid var(--hair); font-size: 14px; color: var(--ink-2); } +.family-row { display: grid; grid-template-columns: 300px minmax(0, 1fr); border-top: 1px solid var(--hair-2); } +.family-row > .fam-side { padding: 22px 24px 22px 0; border-right: 1px solid var(--hair); } +.fam-people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } +.ptile { padding: 22px 24px; border-right: 1px solid var(--hair); border-bottom: 1px solid var(--hair); min-width: 0; } +.ptile .bar-top { display: block; width: 26px; height: 3px; background: var(--pcol, var(--ink-3)); margin-bottom: 12px; } +.ptile h3 { font: 400 28px/1.15 var(--serif); } +.ptile h3 a { text-decoration: none; } +.ptile .meta { font-size: 12.5px; color: var(--ink-3); margin: 6px 0 14px; } +.ptile .meta .warn { color: var(--rev); } +.ptile .lead { font: 16.5px/1.5 var(--serif); color: var(--ink-2); min-height: 4.5em; } +.ptile .counts { display: flex; flex-wrap: wrap; gap: 8px 14px; border-top: 1px solid var(--hair); padding-top: 12px; margin-top: 14px; } +.ptile .counts .lv { font-size: 11.5px; text-transform: none; letter-spacing: 0; font-weight: 500; } +.ptile .read { display: inline-block; margin-top: 14px; font-size: 13px; color: var(--brass); } +.ptile.norun { border-style: dashed; color: var(--ink-3); } +.pc { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; } +.pc::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--pcol, var(--ink-3)); flex: none; } +.pc.nogenome::before { background: none; border: 1.5px dashed var(--ink-3); width: 8px; height: 8px; } +.runlog { width: 100%; border-collapse: collapse; font-size: 13.5px; } +.runlog th { text-align: left; font: 600 10.5px var(--sans); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); padding: 0 12px 10px 0; border-bottom: 1px solid var(--hair-2); } +.runlog td { padding: 12px 12px 12px 0; border-bottom: 1px solid var(--hair); color: var(--ink-2); font-variant-numeric: tabular-nums; } +.runlog td.r, .runlog th.r { text-align: right; } +.runlog .st-finished { color: #3fc43f; font: 600 10.5px var(--sans); letter-spacing: .1em; text-transform: uppercase; } +.runlog .st-running { color: var(--brass); } +.runlog .st-failed { color: #ef6a6a; } +ul.runs { list-style: none; padding: 0; } +ul.runs li { padding: 6px 0; border-bottom: 1px solid var(--hair); } +.kind { color: var(--ink-3); font-size: 12px; margin-left: 6px; } +/* the older home cards, still drawn by the family page's member list */ +.people { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0; } +.person-card .person-link { display: flex; gap: 12px; align-items: flex-start; height: 100%; padding: 14px 16px 14px 0; border-top: 1px solid var(--hair); + color: var(--ink); text-decoration: none; } +.person-card:not(.has-run) .person-link { color: var(--ink-3); } .person-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } -.person-name { font-weight: 600; font-size: 16px; overflow-wrap: anywhere; } -.person-place { color: var(--dim); font-size: 13px; } -.person-state { font-size: 13px; color: var(--dim); } -.person-look { font-size: 13px; color: var(--dim); } -.person-look.attn { color: var(--attn); font-weight: 600; } +.person-name { font: 19px var(--serif); overflow-wrap: anywhere; } +.person-place, .person-state, .person-look { color: var(--ink-3); font-size: 12.5px; } +.person-look.attn { color: var(--rev); } .person-look.attn::before { content: "\25b2 "; font-size: 10px; } -.bar { display: block; height: 5px; border-radius: 3px; background: var(--rule); margin: 6px 0 2px; overflow: hidden; } -.bar-fill { --p: 0; display: block; height: 100%; width: calc(var(--p) * 100%); background: var(--info); } -ul.runs { list-style: none; padding: 0; } -ul.runs li { padding: 6px 0; border-bottom: 1px solid var(--rule); } -.kind { color: var(--dim); font-size: 12px; margin-left: 6px; } +.bar { display: block; height: 3px; border-radius: 2px; background: var(--hair-2); margin: 6px 0 2px; overflow: hidden; } +.bar-fill { --p: 0; display: block; height: 100%; width: calc(var(--p) * 100%); background: var(--brass); } -/* --- a family --- */ -.family-page { display: grid; gap: var(--s4); } -.family-top { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--s4); align-items: start; } -.family-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s4); } +/* --- a family: a report in chapters --- */ +.family { max-width: 1320px; margin: 0 auto; padding: 0 40px 40px; } +.family-page { display: block; } +.family-page > .card, .family-page section.card { border-top: 1px solid var(--hair); padding: 48px 0 8px; margin-top: 48px; background: none; } +.family-page section.card > h2 { font: 400 30px/1.15 var(--serif); margin-bottom: 18px; display: flex; align-items: baseline; } +.family-page section.card > h2 .secnum { font-size: 18px; } +.famcover { padding: 54px 0 0; display: grid; grid-template-columns: minmax(0, 760px) 300px; gap: 56px; } +.famtoc { list-style: none; margin: 10px 0 0; padding: 0; } +.famtoc li a { display: flex; align-items: baseline; padding: 9px 0; border-top: 1px solid var(--hair); color: var(--ink-2); text-decoration: none; font-size: 14px; } +.famtoc li a:hover { color: var(--ink); } +.family-top { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 48px; align-items: start; } +.family-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; } .pedigree-figure { margin: 0; } .pedigree-svg { width: 100%; height: auto; max-height: 340px; display: block; margin: 0 auto; } -.ped-lines line { stroke: var(--dim); stroke-width: 2; } -.ped-shape { fill: var(--card); stroke: var(--text); stroke-width: 2; } -.ped-shape.filled { fill: var(--text); } -.ped-person.link .ped-shape { stroke: var(--info); } -.ped-person.link:hover .ped-shape, .ped-person.link:focus .ped-shape { stroke-width: 4; } -.ped-name { fill: var(--text); font: 13px var(--sans); } -.ped-person.link .ped-name { fill: var(--info); text-decoration: underline; } -.ped-flag { fill: var(--attn); font: 700 12px var(--sans); } -.pedigree-figure figcaption { font-size: 13px; margin-top: var(--s2); } -.pedigree-image summary { cursor: pointer; color: var(--info); font-size: 13px; } -img.pedigree, img.segments { max-width: 100%; height: auto; display: block; margin-top: var(--s2); border-radius: 4px; } +.ped-lines line { stroke: var(--ink-3); stroke-width: 1.5; } +.ped-shape { fill: var(--paper-2); stroke: var(--ink-2); stroke-width: 2; } +.ped-shape.filled { fill: rgba(200, 171, 120, .18); } +.ped-person.link .ped-shape { stroke: var(--brass); } +.ped-person.link:hover .ped-shape, .ped-person.link:focus .ped-shape { stroke-width: 3.5; } +.ped-name { fill: var(--ink-2); font: 13px var(--sans); } +.ped-person.link .ped-name { fill: var(--ink); text-decoration: underline; } +.ped-flag { fill: var(--rev); font: 700 12px var(--sans); } +.pedigree-figure figcaption { font-size: 12.5px; margin-top: 8px; } +.pedigree-image summary { cursor: pointer; color: var(--ink-2); font-size: 12.5px; } +img.pedigree, img.segments { max-width: 100%; height: auto; display: block; margin-top: 8px; border-radius: 4px; } .members .people { grid-template-columns: 1fr; } -.analyses { margin: var(--s3) 0 0; color: var(--dim); font-size: 14px; } -.analyses .label { color: var(--text); } -.plan-item { color: var(--text); } -.plan-item.d-skipped, .plan-item.d-waiting { color: var(--dim); } -.couple-line { max-width: 75ch; } -.couples .caveat-lead { font-size: 13px; color: var(--dim); max-width: 75ch; } -details[data-child] { border-top: 1px solid var(--rule); padding: var(--s2) 0; } -details[data-child] summary { cursor: pointer; } - -/* the relatedness grid (a list of pairs on a phone), a child's painting */ +.analyses { margin: 12px 0 0; color: var(--ink-3); font-size: 13.5px; } +.analyses .label { color: var(--ink); } +.plan-item { color: var(--ink-2); } +.plan-item.d-skipped, .plan-item.d-waiting { color: var(--ink-3); } +.couple-line { max-width: 75ch; font: 18px/1.55 var(--serif); color: var(--ink-2); } +.couples .caveat-lead { font-size: 13px; color: var(--ink-3); max-width: 75ch; } +.kids4 { display: flex; gap: 18px; align-items: center; margin: 16px 0; flex-wrap: wrap; } +.grid4 { display: grid; grid-template-columns: repeat(2, 30px); gap: 4px; } +.grid4 span { width: 30px; height: 30px; border-radius: 5px; border: 1.5px solid var(--car); } +.grid4 span.aff { background: var(--car); } +.grid4 span.non { border-color: var(--hair-2); } +.kids4 .kt { font-size: 13.5px; color: var(--ink-2); } +.kids4 .kt b { color: var(--ink); font-weight: 600; } +details[data-child] { border-top: 1px solid var(--hair); padding: 12px 0; } +details[data-child] summary { cursor: pointer; font: 19px var(--serif); } +details[data-child] h3 { font: 600 10.5px var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); margin: 18px 0 8px; } .rel-grid-wrap { overflow-x: auto; } table.rel-grid { border-collapse: separate; border-spacing: 4px; font-size: 13px; } -table.rel-grid th { color: var(--dim); font-weight: 600; text-align: left; padding: 2px 6px; white-space: nowrap; } -table.rel-grid td { background: var(--card2); border: 1px solid var(--rule); border-radius: 6px; padding: 6px 8px; vertical-align: top; - min-width: 11ch; } +table.rel-grid th { color: var(--ink-3); font-weight: 600; text-align: left; padding: 2px 6px; white-space: nowrap; } +table.rel-grid td { background: var(--paper); border: 1px solid var(--hair-2); border-radius: 6px; padding: 7px 9px; vertical-align: top; min-width: 11ch; } table.rel-grid td span { display: block; } -table.rel-grid td .main { color: var(--text); font-weight: 600; } -table.rel-grid td .sub { color: var(--dim); font-size: 12px; } -table.rel-grid td.self, table.rel-grid td.none { background: transparent; border-style: dashed; color: var(--dim); text-align: center; } -table.rel-grid td.flag { border-color: var(--attn); border-left-width: 4px; } +table.rel-grid td .main { color: var(--ink); font-weight: 600; } +table.rel-grid td .sub { color: var(--ink-3); font-size: 12px; } +table.rel-grid td.self, table.rel-grid td.none { background: transparent; border-style: dashed; color: var(--ink-3); text-align: center; } +table.rel-grid td.flag { border-color: var(--rev); border-left-width: 3px; } table.rel-grid td .chip { margin-top: 4px; white-space: normal; display: inline-flex; } ul.rel-pairs { display: none; list-style: none; margin: 0; padding: 0; } -ul.rel-pairs li { padding: 6px 0; border-bottom: 1px solid var(--rule); } -ul.rel-pairs li.flag { border-left: 4px solid var(--attn); padding-left: 8px; } +ul.rel-pairs li { padding: 7px 0; border-bottom: 1px solid var(--hair); } +ul.rel-pairs li.flag { border-left: 3px solid var(--rev); padding-left: 8px; } ul.rel-pairs .pair { font-weight: 600; } ul.rel-pairs .chip { white-space: normal; } -.legend-line { font-size: 13px; margin: var(--s2) 0; } -details[data-child] h3 { font-size: 15px; margin: var(--s3) 0 var(--s2); } -.painting { margin: 0 0 var(--s3); } +.legend-line { font-size: 12.5px; margin: 8px 0; } +.painting { margin: 0 0 14px; } .paint-grid { display: grid; grid-template-columns: 2.4em minmax(0, 1fr); gap: 3px 6px; align-items: center; } -.paint-chr { color: var(--dim); font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; } -.paint-tracks { display: grid; gap: 2px; } -.paint-track { position: relative; display: block; height: 7px; width: var(--len); border-radius: 4px; - background: color-mix(in srgb, var(--rule) 70%, transparent); overflow: hidden; } +.paint-chr { color: var(--ink-3); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; } +.paint-tracks { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; min-width: 0; } +.paint-track { position: relative; display: block; height: 7px; width: var(--len); border-radius: 4px; background: rgba(255, 255, 255, .07); overflow: hidden; } .paint-seg { position: absolute; top: 0; bottom: 0; left: var(--a); width: var(--w); } .anc1 { background: var(--anc1); } .anc2 { background: var(--anc2); } .anc3 { background: var(--anc3); } .anc4 { background: var(--anc4); } .anc5 { background: var(--anc5); } .paint-seg.other, .paint-swatch.other { background-image: repeating-linear-gradient(45deg, transparent 0 2px, rgba(0, 0, 0, .35) 2px 4px); } -.paint-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; font-size: 13px; margin-top: var(--s2); } +.paint-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; font-size: 12.5px; margin-top: 8px; } .paint-key { display: inline-flex; gap: 6px; align-items: center; } .paint-swatch { display: inline-block; width: 14px; height: 10px; border-radius: 3px; } -.side-by-side table.rows td:first-child { color: var(--dim); font-size: 13px; } +.side-by-side table.rows td:first-child { color: var(--ink-3); font-size: 13px; } .side-by-side table.rows td:nth-child(2) { font-family: var(--mono); font-size: 13px; } +/* --- footer --- */ +.colophon { border-top: 1px solid var(--hair); margin-top: 60px; } +.colophon-in { max-width: 1320px; margin: 0 auto; padding: 26px 40px 40px; display: flex; gap: 40px; flex-wrap: wrap; font-size: 12.5px; color: var(--ink-3); } +.colophon-in .wordmark { font-size: 17px; color: var(--ink-2); } +.colophon-in .right { margin-left: auto; } + /* --- login --- */ body.login main { display: flex; justify-content: center; padding-top: 12vh; } -body.login form { width: 320px; display: flex; flex-direction: column; gap: 8px; } -body.login h1 { font-family: var(--serif); font-size: 24px; font-weight: 500; margin: 0 0 4px; } -body.login .msg { color: var(--attn); min-height: 1em; margin: 0; } -input[type=password] { font: inherit; background: var(--card); color: var(--text); border: 1px solid var(--rule); border-radius: 6px; padding: 8px; } +body.login form { width: 320px; display: flex; flex-direction: column; gap: 10px; } +body.login h1 { font-family: var(--serif); font-size: 30px; margin: 0 0 4px; } +body.login .msg { color: var(--rev); min-height: 1em; margin: 0; } +input[type=password] { font: inherit; background: var(--paper); color: var(--ink); border: 1px solid var(--hair-2); border-radius: 7px; padding: 9px; } -/* --- narrow screens: one column, the rail becomes a row of tabs that scrolls sideways --- */ +/* --- the phone tab bar (a person's report) --- */ +.tabbar { display: none; } + +/* --- narrow screens: one column; on a phone a tab bar at the bottom --- */ +@media (max-width: 1100px) { + .cover, .jlayout { grid-template-columns: minmax(0, 1fr); gap: 24px; } + .cover > div:first-child:empty { display: none; } + .toc { display: none; } + .runhero, .homehero, .famcover { grid-template-columns: minmax(0, 1fr); gap: 32px; } + .writing-card { grid-template-columns: minmax(0, 1fr); } + .writing-card > div + div { border-left: 0; border-top: 1px solid var(--hair); } + .family-row { grid-template-columns: minmax(0, 1fr); } + .family-row > .fam-side { border-right: 0; } +} @media (max-width: 900px) { - .layout { grid-template-columns: minmax(0, 1fr); padding: 0 var(--s3) var(--s6); gap: var(--s2); } - .rail { position: sticky; top: 52px; z-index: 10; flex-direction: row; overflow-x: auto; gap: 4px; padding: var(--s2) 0; - background: var(--bg); scrollbar-width: none; } - .rail [role=tab] { flex: none; border-left: 0; border-bottom: 3px solid transparent; border-radius: 6px 6px 0 0; padding: 6px 10px; } - .rail [role=tab][aria-selected=true] { border-bottom-color: var(--info); } - .rail [data-tab=leaves] { margin-top: 0; } - .progress-grid, .family-top, .family-grid { grid-template-columns: minmax(0, 1fr); } - .person-head { padding: var(--s4) var(--s3) var(--s2); } - .person-head h1 { font-size: 24px; } - .top { padding: 8px var(--s3); gap: var(--s3); } + .row, .cover-main, .runsec .progress-grid, .family-top { grid-template-columns: minmax(0, 1fr); gap: 18px; } + .wrap, .home, .family, .mast-in, .colophon-in, .replay { padding-left: 18px; padding-right: 18px; } + .mast-in { gap: 14px; height: 52px; } .links #home-link { display: none; } - .home, .family { padding: var(--s4) var(--s3) var(--s6); } - .card { padding: var(--s3) var(--s4); } - .result.attn { padding-left: calc(var(--s4) - 3px); } + .actions { flex-direction: row; flex-wrap: wrap; padding-top: 0; } + .ledger { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ledger a { padding-left: 0 !important; border-right: 0; border-bottom: 1px solid var(--hair); } } @media (max-width: 600px) { - .facts { grid-template-columns: minmax(0, 1fr); } + body.page-person { padding-bottom: 68px; } + .h-display { font-size: 44px; } + .homehero .h-display { font-size: 38px; } + .lede { font-size: 19px; } + .dropcap::first-letter { font-size: 58px; padding-right: 6px; } + .trustline { flex-direction: column; align-items: flex-start; gap: 6px; } + .trustline .sep { display: none; } + section.sec { padding-top: 36px; margin-top: 36px; } .search-slot { width: auto; flex: 1; min-width: 0; } .crumbs { display: none; } + .nf > li > details > summary, .nf .nfrow { grid-template-columns: minmax(0, 1fr); gap: 4px; } + .cols2 { columns: 1; } + .timing { grid-template-columns: repeat(2, 1fr); } + .bignum { font-size: 72px; } table.rows thead { display: none; } table.rows, table.rows tbody, table.rows tr, table.rows td { display: block; width: 100%; } - table.rows tr { padding: 6px 0; border-bottom: 1px solid var(--rule); } - table.rows tbody tr:nth-child(even) td { background: none; } - table.rows td { padding: 1px 0; } - table.rows td::before { content: attr(data-label) ": "; color: var(--dim); font-size: 13px; } - .look-list a { flex-direction: column; gap: 0; } - .people { grid-template-columns: minmax(0, 1fr); } + table.rows tr { padding: 6px 0; border-bottom: 1px solid var(--hair); } + table.rows td { padding: 1px 0; border: 0; } + table.rows td::before { content: attr(data-label) ": "; color: var(--ink-3); font-size: 12.5px; } + .runlog thead { display: none; } + .runlog, .runlog tbody, .runlog tr, .runlog td { display: block; width: 100%; } + .runlog tr { padding: 8px 0; border-bottom: 1px solid var(--hair); } + .runlog td { padding: 1px 0; border: 0; text-align: left !important; } + .people, .fam-people { grid-template-columns: minmax(0, 1fr); } .rel-grid-wrap { display: none; } ul.rel-pairs { display: block; } + .tabbar { display: grid; grid-template-columns: repeat(5, 1fr); position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; height: 64px; + background: rgba(14, 15, 16, .96); border-top: 1px solid var(--hair-2); backdrop-filter: blur(8px); } + .tabbar a { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; text-decoration: none; color: var(--ink-3); font-size: 10.5px; } + .tabbar a[aria-current=true] { color: var(--ink); } + .tabbar .lv { font-size: 0; gap: 0; } + .tabbar .ti { font: italic 15px var(--serif); color: var(--brass); } } @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } diff --git a/assets/live/live.js b/assets/live/live.js index efadf6fd..d1af44ba 100644 --- a/assets/live/live.js +++ b/assets/live/live.js @@ -1,10 +1,10 @@ // live.js: the run's progress on a person's page (assets/live/index.html, served by bin/owngenome_live.py): -// the step map, the step detail, "Found so far", the event stream and the replay, shown in the -// Progress tab. One data source feeds the page: in live mode state.json plus the run's event stream; +// the step timeline (one line per step), the step detail, the wire of what each step found, the event +// stream and the replay, in the report's "How the run went" section. One data source feeds the page: in live mode state.json plus the run's event stream; // with a frames.json of recorded states played on a timer. person.js draws // the results from the same states. No outside request, no inline script, no eval. Plugins call // window.PGP.register({name, onState, onEvent, panel, slot}); a panel goes into the element whose id -// is slot when the page has one (the search box in the header), else into the Progress tab. +// is slot when the page has one (the search box in the header), else beside the step timeline. (function () { 'use strict'; var root = document.documentElement; @@ -25,9 +25,8 @@ 'not planned': ['s-notplanned', 'not planned'] }; var SVGNS = 'http://www.w3.org/2000/svg'; - var BOX_W = 170, BOX_H = 34, GAP_X = 36, GAP_Y = 10, PAD = 8; var plugins = []; - var last = null, selected = null, layoutKey = null, boxes = {}; + var last = null, selected = null, boxes = {}; function el(tag, attrs, text) { var e = document.createElement(tag); @@ -47,66 +46,91 @@ return v.length ? v : steps; } - // Columns by depth: a step sits one column right of the deepest step it reads from. - function layout(steps) { - var by = {}, depth = {}; - steps.forEach(function (s) { by[s.step] = s; }); - function d(s, seen) { - if (depth[s.step] !== undefined) { return depth[s.step]; } - if (seen[s.step]) { return 0; } - seen[s.step] = true; - var m = -1; - (s.upstream || []).forEach(function (u) { if (by[u]) { m = Math.max(m, d(by[u], seen)); } }); - depth[s.step] = m + 1; - return depth[s.step]; - } - steps.forEach(function (s) { d(s, {}); }); - var rows = {}, pos = {}; + // The run as a timeline, one line per step (the Journal's step timeline): a step's bar runs from its + // start to its end; a running step's bar ends at now, with its usual hours drawn as an outline after + // it; a step still waiting has no bar yet. Steps are ordered by start, the waiting ones last, in the + // order assets/steps.tsv plans them. Each line is a button (g.box) that opens the step's detail. + function ms(t) { var v = t ? Date.parse(t) : NaN; return isFinite(v) ? v : null; } + function clock(v) { var d = new Date(v); return ('0' + d.getUTCHours()).slice(-2) + ':' + ('0' + d.getUTCMinutes()).slice(-2); } + function timeline(steps, nowText) { + var now = ms(nowText); + var known = steps.map(function (s) { return ms(s.started_utc); }).filter(function (v) { return v !== null; }); + var t0 = known.length ? Math.min.apply(null, known) : null; + var ends = steps.map(function (s) { return ms(s.ended_utc) || (s.state === 'running' ? now : null); }) + .filter(function (v) { return v !== null; }); + var t1 = Math.max.apply(null, ends.concat(known).concat(now !== null ? [now] : []).concat(t0 !== null ? [t0 + 3600e3] : [0])); + var order = steps.map(function (s, i) { return { s: s, i: i, t: ms(s.started_utc) }; }); + order.sort(function (a, b) { + if (a.t === null && b.t === null) { return a.i - b.i; } + if (a.t === null) { return 1; } if (b.t === null) { return -1; } + return a.t - b.t || a.i - b.i; + }); + // Leave room after now for the usual hours of what still runs. + var tail = 0; steps.forEach(function (s) { - var c = depth[s.step]; - rows[c] = (rows[c] || 0) + 1; - pos[s.step] = { x: PAD + c * (BOX_W + GAP_X), y: PAD + (rows[c] - 1) * (BOX_H + GAP_Y) }; + if (s.state === 'running' && s.hours && ms(s.started_utc) !== null) { tail = Math.max(tail, ms(s.started_utc) + s.hours * 3600e3 - t1); } }); - var cols = Math.max.apply(null, Object.keys(rows).map(Number)) + 1; - var maxRows = Math.max.apply(null, Object.keys(rows).map(function (k) { return rows[k]; })); - return { pos: pos, w: 2 * PAD + cols * BOX_W + (cols - 1) * GAP_X, h: 2 * PAD + maxRows * BOX_H + (maxRows - 1) * GAP_Y }; + return { order: order.map(function (o) { return o.s; }), t0: t0, t1: t1 + Math.max(0, Math.min(tail, (t1 - (t0 || t1)) * 0.6)), now: now }; } + var LABEL_W = 190, ROW_H = 16, TOP = 8, AXIS_H = 26; function drawMap(steps) { - var key = steps.map(function (s) { return s.step; }).join(','); var map = document.getElementById('map'); - if (key !== layoutKey) { - layoutKey = key; - boxes = {}; - var L = layout(steps); - var s = svg('svg', { viewBox: '0 0 ' + L.w + ' ' + L.h, width: L.w, height: L.h, role: 'group', 'aria-label': 'Steps of the run' }); - var edges = svg('g', { 'class': 'edges' }); - s.appendChild(edges); - steps.forEach(function (st) { - (st.upstream || []).forEach(function (u) { - var a = L.pos[u], b = L.pos[st.step]; - if (!a) { return; } - edges.appendChild(svg('line', { x1: a.x + BOX_W, y1: a.y + BOX_H / 2, x2: b.x, y2: b.y + BOX_H / 2 })); - }); - }); - steps.forEach(function (st) { - var p = L.pos[st.step]; - var g = svg('g', { 'class': 'box', 'data-step': st.step, tabindex: '0', role: 'button' }); - g.appendChild(svg('rect', { x: p.x, y: p.y, width: BOX_W, height: BOX_H, rx: 5 })); - var t = svg('text', { x: p.x + 8, y: p.y + BOX_H / 2 + 4 }); - var label = st.step + ' ' + st.label; - t.textContent = label.length > 24 ? label.slice(0, 23) + '…' : label; - g.appendChild(t); - var title = svg('title', {}); - g.appendChild(title); - g.addEventListener('click', function () { selected = st.step; detail(); }); - g.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { selected = st.step; detail(); } }); - s.appendChild(g); - boxes[st.step] = g; - }); - map.textContent = ''; - map.appendChild(s); + var T = timeline(steps, last && (last.generated_utc || null)); + var h = TOP + T.order.length * ROW_H + AXIS_H; + // The chart takes the width the page gives it (at least 300 px of time), so no step sits off screen. + var CHART_W = Math.max(300, (map.clientWidth || 962) - LABEL_W - 12); + var W = LABEL_W + CHART_W + 12; + var s = svg('svg', { viewBox: '0 0 ' + W + ' ' + h, width: W, height: h, role: 'group', 'aria-label': 'Steps of the run' }); + var span = T.t0 === null ? 1 : Math.max(T.t1 - T.t0, 60e3); + function x(v) { return LABEL_W + 8 + (T.t0 === null ? 0 : (v - T.t0) / span * (CHART_W - 8)); } + var axis = svg('g', { 'class': 'axis' }); + if (T.t0 !== null) { + var stepH = span > 36 * 3600e3 ? 6 : span > 12 * 3600e3 ? 3 : span > 4 * 3600e3 ? 1 : 0.5; + for (var t = Math.ceil(T.t0 / (stepH * 3600e3)) * stepH * 3600e3; t <= T.t1; t += stepH * 3600e3) { + axis.appendChild(svg('line', { x1: x(t), x2: x(t), y1: TOP - 4, y2: h - AXIS_H + 2 })); + // No hour label under the "now" label of a running run. + if (T.now !== null && last && last.run && last.run.state === 'running' && Math.abs(x(t) - x(T.now)) < 56) { continue; } + var lab = svg('text', { x: x(t), y: h - 8, 'text-anchor': 'middle' }); + lab.textContent = clock(t); + axis.appendChild(lab); + } + } + s.appendChild(axis); + boxes = {}; + T.order.forEach(function (st, i) { + var y = TOP + i * ROW_H; + var g = svg('g', { 'class': 'box', 'data-step': st.step, tabindex: '0', role: 'button' }); + g.appendChild(svg('rect', { 'class': 'hit', x: 0, y: y, width: W, height: ROW_H })); + var t = svg('text', { 'class': 'lbl', x: LABEL_W, y: y + ROW_H - 4, 'text-anchor': 'end' }); + var label = st.label || st.step; + t.textContent = label.length > 30 ? label.slice(0, 29) + '…' : label; + g.appendChild(t); + var a = ms(st.started_utc), b = ms(st.ended_utc) || (st.state === 'running' ? T.now : null); + if (a !== null && T.t0 !== null) { + var x1 = x(a), x2 = Math.max(x(b !== null ? b : a), x1 + 3); + g.appendChild(svg('rect', { 'class': 'bar', x: x1, y: y + 4, width: x2 - x1, height: ROW_H - 8, rx: 1.5 })); + if (st.state === 'running' && st.hours) { + var u = x(a + st.hours * 3600e3); + if (u > x2) { g.appendChild(svg('rect', { 'class': 'usual', x: x2, y: y + 4.5, width: Math.min(u, W - 4) - x2, height: ROW_H - 9, rx: 1.5 })); } + } + } else { + g.appendChild(svg('rect', { 'class': 'bar', x: LABEL_W + 8, y: y + 5, width: 0, height: 0 })); + } + g.appendChild(svg('title', {})); + g.addEventListener('click', function () { selected = st.step; detail(); }); + g.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { selected = st.step; detail(); } }); + s.appendChild(g); + boxes[st.step] = g; + }); + if (T.now !== null && T.t0 !== null && last && last.run && last.run.state === 'running') { + s.appendChild(svg('line', { 'class': 'nowline', x1: x(T.now), x2: x(T.now), y1: TOP - 4, y2: h - AXIS_H + 2 })); + var nl = svg('text', { 'class': 'nowlab', x: x(T.now), y: h - 8, 'text-anchor': 'middle' }); + nl.textContent = 'now ' + clock(T.now); + s.appendChild(nl); } + map.textContent = ''; + map.appendChild(s); steps.forEach(function (st) { paint(st.step, st.state, st.label); }); } diff --git a/assets/live/login.html b/assets/live/login.html index 2b58c7da..24f4b067 100644 --- a/assets/live/login.html +++ b/assets/live/login.html @@ -9,7 +9,7 @@
-

OwnGenome

+

OwnGenome

{{message}}

diff --git a/assets/live/person.js b/assets/live/person.js index 6a708403..932ddd76 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -1,24 +1,26 @@ -// person.js: a person's results on their page (assets/live/index.html), from results.json -// (bin/live_results.py, served by bin/owngenome_live_results.py) and the run's states (live.js). -// The rail holds the report's seven chapter questions, "Look first" (every card worth a look) and -// "What this leaves out"; Progress is live.js's step map. The tab lives in the URL fragment only -// (#q/, #s/
, #progress), which the browser never sends, so no tab or search reaches -// a server log. A card shows its results once one of its steps is done in the current state, so live -// and replay draw the same way and a replay reveals the results as the run goes. +// person.js: a person's genome as a report, the Journal design (docs/design/journal/), on their page +// (assets/live/index.html), from results.json (bin/live_results.py, served by +// bin/owngenome_live_results.py) and the run's states (live.js). The page reads top to bottom: the lead +// paragraph built from the findings, the counts by level, the chromosome map of the findings, then ten +// numbered sections: 1 To act on, 2 Medicines, 3 For your family, 4 Worth a look, 5 Can I trust this?, +// 6 Origins, 7 Traits and scores, 8 Assessed, no finding, 9 What it cannot see, 10 Sources and files. +// A run still going is the same report being written: each section carries its stamp (written, being +// written, waiting) and the step timeline sits on top. Every card of results.json is on the page under +// its section; a card shows its results once one of its steps is done in the current state, so live +// and replay draw the same way. Links inside the page live in the fragment (#s/, #s3, #f/, +// #progress), which the browser never sends. Silence is never an all-clear: a section with no finding +// says what its steps looked at and what they do not see. (function () { 'use strict'; if (!window.PGP || !window.PGPUI) { return; } var U = window.PGPUI, el = U.el, add = U.add; var root = document.documentElement; var replay = root.getAttribute('data-mode') === 'replay'; - var results = null, state = null, fetchedFor = null, current = null, chosen = false, drawnKey = null; - var rail = document.getElementById('rail'); + var results = null, state = null, family = null, familyFor = null, fetchedFor = null, drawnKey = null; + var shown = {}; // veiled findings the reader opened, by id var panel = document.getElementById('results'); - var progress = document.getElementById('progress'); - var SHORT = { quality: 'Trust', clinical: 'Doctor', carrier: 'Child', medicines: 'Medicines', ancestry: 'Ancestors', - traits: 'Traits', other: 'Other' }; - var LOOK_EMPTY = 'Nothing on this list. That is information, not a clean bill: each chapter’s caveats and ' + - '“What this leaves out” still apply.'; + var rail = document.getElementById('rail'); + var NOT_ALL_CLEAR = 'No finding in what these steps assessed.'; function stepsBy() { var by = {}; @@ -29,100 +31,81 @@ return ((state && state.steps) || []).filter(function (s) { return s.state === 'done'; }) .map(function (s) { return s.step; }).join(','); } - function anyDone() { return doneSet() !== ''; } - // What a card can show at this moment: 'show', 'waiting' (a step still to come), 'notrun' (none planned) - // or 'empty' (its steps ended and wrote nothing for it). + function running() { return !!(state && state.run && state.run.state === 'running'); } + // What a card can show at this moment: 'show', 'waiting' (a step still to come), 'running' (one of its + // steps runs now), 'failed', 'notrun' (none planned) or 'empty' (its steps ended and wrote nothing). function gate(c) { var by = stepsBy(), steps = c.steps || []; var known = steps.filter(function (s) { return by[s]; }); if (known.some(function (s) { return by[s].state === 'done'; })) { return c.state === 'missing' ? 'empty' : 'show'; } - if (known.some(function (s) { return ['waiting', 'running'].indexOf(by[s].state) >= 0; })) { return 'waiting'; } + if (known.some(function (s) { return by[s].state === 'running'; })) { return 'running'; } + if (known.some(function (s) { return by[s].state === 'waiting'; })) { return 'waiting'; } if (known.some(function (s) { return by[s].state === 'failed'; })) { return 'failed'; } if (!known.length && c.state !== 'missing' && !replay) { return 'show'; } return 'notrun'; } - function waitingText(c) { + function pending(g) { return g === 'waiting' || g === 'running'; } + function stepWords(c) { var by = stepsBy(); var s = (c.steps || []).filter(function (x) { return by[x] && by[x].state !== 'done'; })[0] || (c.steps || [])[0]; - return 'Waiting for step ' + s + (by[s] ? ' (' + by[s].label + ')' : '') + '.'; + return s ? (by[s] ? by[s].label : 'step ' + s) : 'its step'; } function cards() { var out = []; - ((results && results.chapters) || []).forEach(function (ch) { - ch.sections.forEach(function (c) { out.push({ ch: ch, c: c }); }); - }); + ((results && results.chapters) || []).forEach(function (ch) { ch.sections.forEach(function (c) { out.push(c); }); }); return out; } - function attentionNow(c) { return c.attention && gate(c) === 'show'; } - - // --- the route -------------------------------------------------------------------------------- - function parse() { - var h; - try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return null; } // a broken fragment - if (h === 'progress') { return { tab: 'progress' }; } - if (h.indexOf('q/') === 0) { return { tab: h.slice(2) }; } - if (h.indexOf('s/') === 0) { return { tab: 'section', id: h.slice(2) }; } - if (h && cards().some(function (x) { return x.c.id === h; })) { return { tab: 'section', id: h }; } // an old #clinvar link - return null; - } - function chapterOf(id) { - var hit = cards().filter(function (x) { return x.c.id === id; })[0]; - return hit ? hit.ch.key : null; - } - function defaultTab() { return results && results.chapters && results.chapters.length && anyDone() ? 'look' : 'progress'; } - - function route(focus) { - var r = parse() || { tab: defaultTab() }; - var tab = r.tab === 'section' ? (chapterOf(r.id) || 'look') : r.tab; - var valid = ['progress', 'look', 'leaves'].concat(((results && results.chapters) || []).map(function (c) { return c.key; })); - if (valid.indexOf(tab) < 0) { tab = defaultTab(); } - show(tab); - Array.prototype.forEach.call(rail.querySelectorAll('[role=tab]'), function (t) { - var on = t.getAttribute('data-tab') === tab; - t.setAttribute('aria-selected', on ? 'true' : 'false'); - t.tabIndex = on ? 0 : -1; - }); - if (r.tab === 'section') { - var card = document.getElementById('card-' + r.id); - if (card) { - var d = card.querySelector('details.caveat-full'); - if (d && focus !== false) { card.classList.add('target'); } - card.scrollIntoView({ block: 'start' }); - card.focus({ preventScroll: true }); - } - } else if (window.location.hash === '#progress') { - // #progress is also the section's id, so the browser jumps to it: keep the head and the rail in view. - setTimeout(function () { window.scrollTo(0, 0); }, 0); - } - } - function go(hash) { - if (window.location.hash === hash) { route(); } else { window.location.hash = hash; } + function cardById(id) { return cards().filter(function (c) { return c.id === id; })[0]; } + function findingById(id) { return ((results && results.findings) || []).filter(function (f) { return f.id === id; })[0]; } + function findingsNow() { + return ((results && results.findings) || []).filter(function (f) { var c = cardById(f.card); return c && gate(c) === 'show'; }); } - window.addEventListener('hashchange', function () { chosen = true; route(); }); + function sectionsList() { return (results && results.journal) || []; } + function attentionNow(c) { return c.attention && gate(c) === 'show'; } + function personName() { return (results && results.person && results.person.sample) || root.getAttribute('data-run') || ''; } - // --- the rail --------------------------------------------------------------------------------- - function drawRail() { - rail.textContent = ''; - function tab(key, label, n, hash) { - var a = el('a', { role: 'tab', href: hash, id: 'tab-' + key, 'data-tab': key, 'aria-controls': key === 'progress' ? 'progress' : 'results' }); - add(a, el('span', { 'class': 'tab-label' }, label)); - if (n) { add(a, el('span', { 'class': 'count', 'aria-label': n + ' worth a look' }, String(n))); } - add(rail, a); - return a; - } - if (results && results.chapters && results.chapters.length) { - var all = cards().filter(function (x) { return attentionNow(x.c); }).length; - tab('look', 'Look first', all, '#q/look'); - results.chapters.forEach(function (ch) { - var n = ch.sections.filter(attentionNow).length; - var t = tab(ch.key, SHORT[ch.key] || ch.title, n, '#q/' + ch.key); - t.setAttribute('title', ch.question); - }); - tab('leaves', 'Leaves out', 0, '#q/leaves'); + // --- the family this person is in: family.json of the first family the family plugin names ------- + function familyUrl() { + var fam = state && state.plugins && state.plugins.family; + var f = fam && fam.kind === 'member' && (fam.families || [])[0]; + return f ? f : null; + } + function loadFamily() { + var f = familyUrl(); + var key = f ? f.url : null; + if (key === familyFor) { return; } + familyFor = key; + if (!f) { family = null; return; } + U.getJSON(f.url + 'family.json').then(function (d) { + if (familyFor !== key) { return; } + family = d && d.state ? { name: f.name, url: f.url, data: d } : null; + redraw(true); + }).catch(function () { family = null; }); + } + function me() { + var ms = (family && family.data.state && family.data.state.members) || []; + return ms.filter(function (m) { return m.sample === personName(); })[0] || null; + } + // A child of the family: a member whose father or mother is listed. Results to act on about a child + // stay closed until someone opens them on purpose. + function isChild() { var m = me(); return !!(m && (m.father || m.mother)); } + function memberUrl(s) { + var r = family && family.data.runs && family.data.runs[s]; + return r && r.url ? r.url : null; + } + function role() { + var m = me(); + if (!m) { return null; } + var ms = family.data.state.members; + var kids = ms.filter(function (x) { return x.father === m.sample || x.mother === m.sample; }); + var bits = []; + if (m.father || m.mother) { + bits.push(({ male: 'son', female: 'daughter' }[m.sex] || 'child') + ' of ' + [m.father, m.mother].filter(Boolean).join(' and ')); } - tab('progress', 'Progress', 0, '#progress'); + if (kids.length) { bits.push(({ male: 'father', female: 'mother' }[m.sex] || 'parent') + ' of ' + kids.map(function (k) { return k.sample; }).join(', ')); } + if (m.partner) { bits.push('partner of ' + m.partner); } + return bits.join(', '); } - U.tablist(rail, function (t) { go(t.getAttribute('href')); }); // --- widgets: a card's numbers drawn --------------------------------------------------------------- // HTML placed in percent of a track (custom properties, never a style attribute), so the text stays @@ -292,45 +275,49 @@ try { return fn(w); } catch (e) { return el('p', { 'class': 'dim' }, 'This drawing could not be made; the table below has the numbers.'); } } - // --- a card ----------------------------------------------------------------------------------- - function badge(text, cls, symbol) { - var b = el('span', { 'class': 'chip ' + cls }); - if (symbol) { add(b, el('span', { 'class': 'sym', 'aria-hidden': 'true' }, symbol)); } - add(b, text); - return b; - } - function recheckBadges(c) { + // --- a card: one section of the report, with its numbers, its rows, its figure and its caveat ------- + function recheckWords(c) { var r = state && state.plugins && state.plugins.recheck; var out = []; (c.steps || []).forEach(function (s) { - ((r && r.steps && r.steps[s]) || []).forEach(function (t) { out.push(badge(t, 'chip-recheck', '↻')); }); + ((r && r.steps && r.steps[s]) || []).forEach(function (t) { if (out.indexOf(t) < 0) { out.push(t); } }); }); return out; } - function card(c, ch) { + function chip(text, cls, symbol) { + var b = el('span', { 'class': 'chip ' + cls }); + if (symbol) { add(b, el('span', { 'class': 'sym', 'aria-hidden': 'true' }, symbol)); } + add(b, text); + return b; + } + function card(c) { var g = gate(c); - var cls = 'card result g-' + g + (attentionNow(c) ? ' attn' : ''); - var art = el('article', { 'class': cls, id: 'card-' + c.id, 'data-section': c.id, 'data-step': (c.steps || []).join(' '), - 'data-state': c.state, tabindex: '-1', 'aria-labelledby': 'h-' + c.id }); + var art = el('article', { 'class': 'card result g-' + g + (attentionNow(c) ? ' attn' : ''), id: 'card-' + c.id, + 'data-section': c.id, 'data-step': (c.steps || []).join(' '), 'data-state': c.state, 'data-level': c.level || '', + tabindex: '-1', 'aria-labelledby': 'h-' + c.id }); var head = el('header', { 'class': 'card-head' }); var h = el('h3', { id: 'h-' + c.id }); add(h, el('a', { href: '#s/' + c.id, 'class': 'self' }, c.title)); add(head, h); var chips = el('div', { 'class': 'chips' }); if (g === 'show') { - add(chips, attentionNow(c) ? badge('worth a look', 'chip-attn', '▲') : badge('for information', 'chip-info', '○')); + // The card's level, on the one scale: a card the report flags is "worth a look" at least. + var lv = c.level || (attentionNow(c) ? 'review' : 'info'); + add(chips, U.level(lv, attentionNow(c) && lv === 'info' ? 'Worth a look' : undefined)); } - recheckBadges(c).forEach(function (b) { add(chips, b); }); + recheckWords(c).forEach(function (t) { add(chips, chip(t, 'chip-recheck', '↻')); }); add(head, chips); add(art, head); - if (g === 'waiting') { - add(art, el('p', { 'class': 'state-text' }, waitingText(c))); + if (pending(g)) { + add(art, el('p', { 'class': 'state-text' }, (g === 'running' ? 'Being written: ' : 'Waiting for ') + stepWords(c) + + (g === 'running' ? ' is running now.' : '.'))); } else if (g === 'notrun') { - add(art, el('p', { 'class': 'state-text' }, 'Not run in this run (step ' + (c.steps || []).join(', ') + ').')); + add(art, el('p', { 'class': 'notassessed' }, 'Not assessed: no step of this run made this section (step ' + (c.steps || []).join(', ') + ').')); } else if (g === 'failed') { - add(art, el('p', { 'class': 'state-text' }, 'Step ' + (c.steps || []).join(', ') + ' failed: the Progress tab says why.')); + add(art, add(el('p', { 'class': 'notassessed' }), el('b', {}, 'Not assessed. '), + 'Step ' + (c.steps || []).join(', ') + ' failed: the timeline below says why, and a rerun of the step fills this section.')); } else if (g === 'empty') { - add(art, el('p', { 'class': 'state-text' }, 'Its step ended without a result for this section.')); + add(art, el('p', { 'class': 'notassessed' }, 'Not assessed: its step ended without a result for this section.')); } else { (c.notes || []).forEach(function (n) { add(art, el('p', { 'class': 'note-stale' }, n)); }); if (c.headline) { add(art, el('p', { 'class': 'headline' }, c.headline)); } @@ -348,6 +335,7 @@ add(art, ul); } if (c.hidden) { + // APOE and the like: behind their warning, opened on purpose. var d = el('details', { 'class': 'behind' }); add(d, el('summary', {}, c.hidden.summary), el('p', {}, c.hidden.text)); add(art, d); @@ -374,119 +362,480 @@ return art; } - // --- the views -------------------------------------------------------------------------------- - function heading(text, sub) { - var h = el('header', { 'class': 'view-head' }); - add(h, el('h2', { 'class': 'question' }, text)); - if (sub) { add(h, el('p', { 'class': 'dim' }, sub)); } - return h; + // --- a finding in its section ------------------------------------------------------------------------ + function findingHref(f) { return 'finding/' + encodeURIComponent(f.id) + '/'; } + function newAt(f) { + if (!running()) { return null; } + var by = stepsBy(), t = null; + (f.steps || []).forEach(function (s) { if (by[s] && by[s].ended_utc) { t = by[s].ended_utc; } }); + return t; } - function lookFirst() { - var box = el('div', { id: 'look-first', 'class': 'view' }); - add(box, heading('Look at these first', 'The cards the report itself flags, in chapter order. A card not listed is for information; its caveats still apply.')); - var items = cards().filter(function (x) { return attentionNow(x.c); }); - if (!items.length) { - add(box, el('p', { 'class': 'empty look-empty' }, LOOK_EMPTY)); - } else { - var ol = el('ol', { 'class': 'look-list' }); - items.forEach(function (x) { - var li = el('li', {}); - var a = el('a', { href: '#s/' + x.c.id }); - add(a, el('span', { 'class': 'look-ch' }, SHORT[x.ch.key] || x.ch.title), el('span', { 'class': 'look-title' }, x.c.title)); - add(li, a); - if (x.c.headline) { add(li, el('p', { 'class': 'look-head' }, x.c.headline)); } - add(ol, li); - }); - add(box, ol); + function note(head, body, cls) { + var n = el('div', { 'class': 'note' + (cls ? ' ' + cls : '') }); + add(n, el('span', { 'class': 'nh' }, head)); + add(n, body); + return n; + } + function evidence(rows) { + if (!rows || !rows.length) { return null; } + var dl = el('dl', {}); + rows.forEach(function (r) { add(dl, el('dt', {}, r[0]), el('dd', {}, r[1])); }); + return dl; + } + function caveatNote(list) { + if (!list || !list.length) { return null; } + var box = el('div', {}); + var lead = U.firstSentence(list[0]); + add(box, el('span', {}, lead + ' ')); + var d = el('details', {}); + add(d, el('summary', { 'class': 'toggle' }, 'More')); + list.forEach(function (t) { add(d, el('p', {}, t)); }); + add(box, d); + return note('Caveat', box, 'caveat-note'); + } + function veiled(f) { return isChild() && f.level === 'act' && !shown[f.id]; } + function findingArticle(f, big) { + var row = el('div', { 'class': 'row' }); + var art = el('div', { 'class': 'finding', id: 'f-' + f.id, 'data-finding': f.id, 'data-level': f.level }); + var at = newAt(f); + if (at) { art.classList.add('new'); add(art, el('span', { 'class': 'newband' }, 'New at ' + U.hm(at))); add(art, el('br', {})); } + add(art, U.level(f.level, f.level_words)); + if (veiled(f)) { + var v = el('div', { 'class': 'veil' }); + add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), + 'A result to act on in adult life is usually not looked at before the child can choose. It stays closed until you open it.')); + var b = el('button', { type: 'button', 'class': 'btn' }, 'Show the result'); + b.addEventListener('click', function () { shown[f.id] = true; redraw(true); var x = document.getElementById('f-' + f.id); if (x) { x.scrollIntoView({ block: 'start' }); } }); + add(v, b); + add(art, v); + add(row, art, el('aside', {})); + return row; } - var waiting = cards().filter(function (x) { return gate(x.c) === 'waiting'; }).length; - if (waiting) { add(box, el('p', { 'class': 'dim' }, (waiting === 1 ? '1 card is' : waiting + ' cards are') + ' still waiting for its step.')); } - return box; + var h = el('h3', { 'class': big ? 'h1' : 'h3' }); + add(h, el('a', { href: findingHref(f) }, f.title)); + add(art, h); + if (f.detail) { add(art, el('div', { 'class': 'gene' }, f.detail)); } + add(art, el('p', { 'class': 'body-serif' }, f.text)); + if (big && f.plan && f.plan.length) { + add(art, el('div', { 'class': 'todo-h' }, 'What to do')); + var ol = el('ol', { 'class': 'todo' }); + f.plan.forEach(function (t) { add(ol, el('li', {}, t)); }); + add(art, ol); + } + add(art, el('a', { 'class': 'readmore', href: findingHref(f) }, 'The full evidence and what to do →')); + var side = el('aside', {}); + var ev = evidence(f.evidence); + if (ev) { add(side, note('Evidence', ev)); } + add(side, caveatNote(f.caveat)); + var rel = (f.related || []).map(findingById).filter(Boolean); + if (rel.length) { + var box = el('div', {}); + rel.slice(0, 4).forEach(function (r, i) { if (i) { add(box, el('br', {})); } add(box, el('a', { href: '#f/' + r.id }, r.title)); }); + add(side, note('Linked', box)); + } + add(row, art, side); + return row; } - function chapter(ch) { - var box = el('div', { 'class': 'view chapter', 'data-chapter': ch.key }); - add(box, heading(ch.question, ch.title)); - var grid = el('div', { 'class': 'cards' }); - ch.sections.forEach(function (c) { add(grid, card(c, ch)); }); - add(box, grid); - return box; + function findingLine(f) { + var li = el('li', { id: 'f-' + f.id, 'data-finding': f.id, 'data-level': f.level }); + add(li, el('a', { 'class': 't', href: findingHref(f) }, f.title), U.level(f.level, f.level_words)); + if (f.detail) { add(li, el('span', { 'class': 'gene' }, f.detail)); } + add(li, el('span', { 'class': 'x' }, f.text)); + return li; } - function leaves() { - var box = el('div', { 'class': 'view' }); - add(box, heading('What this leaves out', 'What no step looks at, and the steps that did not run.')); - function list(title, items) { - var s = el('section', { 'class': 'card' }); - add(s, el('h3', {}, title)); - var ul = el('ul', { 'class': 'lines' }); - (items.length ? items : ['None.']).forEach(function (t) { add(ul, el('li', {}, t)); }); - add(s, ul); - add(box, s); + + // --- the sections --------------------------------------------------------------------------------- + // A section's stamp: written (with the time its last step ended), being written (a step runs), waiting. + function stamp(sec) { + var cs = sec.cards.map(cardById).filter(Boolean); + if (!cs.length) { return { k: 'written', t: 'Written' }; } + var gs = cs.map(gate); + if (gs.some(function (g) { return g === 'running'; })) { return { k: 'writing', t: 'Being written' }; } + if (gs.some(function (g) { return g === 'waiting'; })) { + return gs.some(function (g) { return g === 'show'; }) ? { k: 'writing', t: 'Half written' } : { k: 'waiting', t: 'Waiting' }; } - list('No step looks at these', (results && results.not_assessed) || []); - list('Steps that did not run', (results && results.not_run) || []); + var by = stepsBy(), t = null; + cs.forEach(function (c) { (c.steps || []).forEach(function (s) { if (by[s] && by[s].ended_utc && (!t || by[s].ended_utc > t)) { t = by[s].ended_utc; } }); }); + return { k: 'written', t: 'Written' + (t && running() ? ' ' + U.hm(t) : '') }; + } + function sectionHead(sec) { + var head = el('div', { 'class': 'sechead' }); + add(head, el('span', { 'class': 'secnum' }, String(sec.n)), el('h2', { 'class': 'h2' }, sec.title)); + if (running()) { var st = stamp(sec); add(head, el('span', { 'class': 'stamp ' + st.k }, st.t)); } + return head; + } + function waitBlock(sec) { + var cs = sec.cards.map(cardById).filter(function (c) { return c && pending(gate(c)); }); + if (!cs.length) { return null; } + var box = el('div', {}); + add(box, add(el('div', { 'class': 'proof', 'aria-hidden': 'true' }), el('i', {}), el('i', {}), el('i', {}))); + var names = cs.map(function (c) { return c.title + (gate(c) === 'running' ? ' (running)' : ''); }); + add(box, el('p', { 'class': 'waitnote' }, 'This section stays open until its steps finish: ' + names.join(', ') + + '. Until then this page says nothing either way.')); return box; } - function show(tab) { - current = tab; - var isProgress = tab === 'progress'; - progress.hidden = !isProgress; - panel.hidden = isProgress; - root.setAttribute('data-pgp-tab', tab); - if (isProgress) { return; } + // What the clinical steps of section 8 looked at, named in an empty section 1 to 4. + function assessedNames() { + var s8 = sectionsList().filter(function (s) { return s.n === 8; })[0]; + return s8 ? s8.cards.map(cardById).filter(function (c) { return c && gate(c) === 'show'; }).map(function (c) { return c.title; }) : []; + } + function sectionBody(sec) { + var body = el('div', {}); + var fs = sec.findings.map(findingById).filter(function (f) { var c = f && cardById(f.card); return c && gate(c) === 'show'; }); + var cs = sec.cards.map(cardById).filter(Boolean); + var big = sec.n === 1 || (sec.n === 4 && fs.length <= 2); + if (sec.n <= 4 || sec.n === 5) { + if (fs.length) { + if (big || sec.n === 1 || fs.length <= 3) { fs.forEach(function (f) { add(body, findingArticle(f, big)); }); } + else { var ul = el('ul', { 'class': 'list-findings' }); fs.forEach(function (f) { add(ul, findingLine(f)); }); add(body, ul); } + } else if (sec.n !== 5) { + var w = waitBlock(sec); + var pend = sec.n === 1 && cards().some(function (c) { return c.section === 8 && pending(gate(c)); }); + if (w) { add(body, w); } else if (pend) { + add(body, el('p', { 'class': 'waitnote' }, 'This section stays open until the clinical steps finish. Until then this page says nothing either way.')); + } else { + var e = el('p', { 'class': 'empty-sec' }, NOT_ALL_CLEAR); + add(body, e); + var names = sec.n === 1 || sec.n === 4 ? assessedNames() : cs.filter(function (c) { return gate(c) === 'show'; }).map(function (c) { return c.title; }); + if (names.length) { add(body, el('p', { 'class': 'soft' }, 'Assessed: ' + names.join(', ') + '. Section 8 lists what each looked at, section 9 what no step sees.')); } + } + } + } + if (sec.n === 8) { + var nf = el('ul', { 'class': 'nf' }); + cs.forEach(function (c) { + var li = el('li', {}); + var d = el('details', { 'data-card': c.id }); + var sum = el('summary', {}); + var g = gate(c); + var what = g === 'show' ? (c.headline || (c.facts && c.facts[0] ? c.facts[0][0] + ': ' + c.facts[0][1] : NOT_ALL_CLEAR)) + : pending(g) ? 'Waiting for ' + stepWords(c) + '.' : g === 'failed' ? 'Not assessed: its step failed.' : 'Not assessed in this run.'; + var w2 = el('span', { 'class': 'what' }, what); + if (c.caveat && c.caveat.length) { add(w2, el('span', { 'class': 'cav' }, 'Does not see: ' + U.firstSentence(c.caveat[0]))); } + add(sum, el('span', { 'class': 'nfname' }, c.title), w2); + add(d, sum, card(c)); + add(li, d); + add(nf, li); + }); + add(body, nf); + return body; + } + if (sec.n === 9) { + var lists = [['No step looks at these', (results && results.not_assessed) || []], ['Steps that did not run', (results && results.not_run) || []]]; + lists.forEach(function (x) { + add(body, el('h3', { 'class': 'h3' }, x[0])); + var ul2 = el('ul', { 'class': 'cols2' }); + (x[1].length ? x[1] : ['None listed.']).forEach(function (t) { add(ul2, el('li', {}, t)); }); + add(body, ul2); + }); + return body; + } + if (sec.n === 10) { + var dbs = (results && results.databases) || {}; + var keys = Object.keys(dbs); + if (keys.length) { + var t = el('table', { 'class': 'sources' }); + add(t, add(el('tr', {}), el('th', {}, 'Source'), el('th', {}, 'Version or date'))); + keys.forEach(function (k) { add(t, add(el('tr', {}), el('td', {}, k.replace(/_/g, ' ')), el('td', {}, dbs[k]))); }); + add(body, t); + } + var rc = state && state.plugins && state.plugins.recheck; + if (rc && rc.banner) { add(body, el('p', { 'class': 'note-stale' }, rc.banner)); } + var fb = el('div', { 'class': 'filebtns' }); + add(fb, el('a', { 'class': 'btn', href: 'report.html' }, 'The full report (HTML)')); + add(fb, el('a', { 'class': 'btn ghost', href: 'viewer/' }, 'Genome viewer')); + add(body, fb); + } + cs.forEach(function (c) { add(body, card(c)); }); + return body; + } + function drawSections() { panel.textContent = ''; if (results && results.note && !(results.chapters || []).length) { add(panel, el('p', { 'class': 'empty' }, results.note)); return; } - if (tab === 'look') { add(panel, lookFirst()); } else if (tab === 'leaves') { add(panel, leaves()); } else { - var ch = ((results && results.chapters) || []).filter(function (c) { return c.key === tab; })[0]; - if (ch) { add(panel, chapter(ch)); } - } + sectionsList().forEach(function (sec) { + var s = el('section', { 'class': 'sec', id: 's' + sec.n, 'data-journal': sec.key, 'aria-labelledby': 'sh' + sec.n }); + var head = sectionHead(sec); + head.querySelector('h2').id = 'sh' + sec.n; + add(s, head, el('div', { 'class': 'secq' }, sec.question)); + add(s, sectionBody(sec)); + add(panel, s); + }); root.setAttribute('data-pgp-results', String(panel.querySelectorAll('article.result').length)); + root.setAttribute('data-pgp-findings', String(panel.querySelectorAll('[data-finding]').length)); document.dispatchEvent(new CustomEvent('pgp:cards')); } - // --- the header ------------------------------------------------------------------------------- + // --- the lead, the counts by level, the chromosome map --------------------------------------------- + function counts() { + var n = { act: 0, medicine: 0, review: 0, carrier: 0, trust: 0, info: 0 }; + findingsNow().forEach(function (f) { n[f.level] += 1; }); + n.info = cards().filter(function (c) { return gate(c) === 'show' && c.level === 'info'; }).length; + return n; + } + function drawLead() { + var slot = document.getElementById('lead-slot'); + slot.textContent = ''; + if (!results || !(results.chapters || []).length) { return; } + var row = el('div', { 'class': 'row' }); + var main = el('div', {}); + var p = el('p', { 'class': 'lede dropcap', id: 'lead' }); + var parts = (results.lead || []).slice(); + if (running()) { + parts = [['This report is being written. ', true], ['What follows is what has been found so far; a section with steps still to run says so. ', false]] + .concat(parts.filter(function (x) { return !/^Mean depth/.test(x[0]); })); + } + parts.forEach(function (x) { add(p, x[1] ? el('b', {}, x[0]) : x[0]); }); + add(main, p); + var n = counts(); + var fs = findingsNow(); + var led = el('div', { 'class': 'ledger', id: 'ledger' }); + [['act', 1], ['medicine', 2], ['review', 4], ['carrier', 3], ['info', 6]].forEach(function (x) { + var a = el('a', { href: '#s' + x[1], 'data-level': x[0] }); + add(a, U.level(x[0], { act: 'Take to a doctor', medicine: 'Prescriber', review: 'Worth a look', carrier: 'Family planning', info: 'Information' }[x[0]])); + add(a, el('span', { 'class': 'n' }, String(n[x[0]]))); + var words = x[0] === 'info' ? 'Origins, traits, scores' : + fs.filter(function (f) { return f.level === x[0]; }).slice(0, 2).map(function (f) { return f.gene || f.title; }).join(', ') || 'None in what was assessed'; + add(a, el('span', { 'class': 'w' }, words)); + add(led, a); + }); + add(main, led); + var trust = el('div', { 'class': 'trustline' }); + var problems = fs.filter(function (f) { return f.level === 'trust'; }); + add(trust, U.level('trust', 'Data quality', problems.length ? 'problem' : '')); + var qc = cardById('qc'); + if (qc && gate(qc) === 'show') { + (qc.facts || []).filter(function (f) { return /depth|contamination|sex from/i.test(f[0]); }).slice(0, 3).forEach(function (f, i) { + if (i) { add(trust, el('span', { 'class': 'sep' }, '·')); } + add(trust, el('span', { title: f[0] + ': ' + f[1] }, String(f[0]).replace(/\s*\([^)]*\)/g, '') + ': ' + String(f[1]).split(/[.;(]\s/)[0].replace(/[.;]$/, '').trim())); + }); + } else { add(trust, el('span', {}, 'Waiting for the quality steps.')); } + problems.forEach(function (f) { add(trust, el('span', { 'class': 'sep' }, '·'), el('a', { href: '#f/' + f.id }, f.title)); }); + add(trust, el('span', { 'class': 'sep' }, '·'), el('a', { href: '#s5' }, 'Can I trust this?')); + add(main, trust); + var side = el('aside', {}); + add(side, note('How to read this', el('span', {}, 'Sections run from what matters most to what is only interesting. A section with nothing to report still says what it looked at and what it cannot see.'))); + add(side, note('A research pipeline', el('span', {}, 'Not a clinical test: anything to act on is confirmed by a certified laboratory first.'))); + add(row, main, side); + add(slot, row); + // Figure 1: where the findings sit. + var marks = []; + fs.forEach(function (f) { + var l = U.locus(f.viewer); + if (l && !veiled(f)) { marks.push({ chr: l.chr, mb: l.mb, lv: f.level, label: f.gene || '', tip: f.title, href: findingHref(f) }); } + }); + var fig = el('figure', { 'class': 'row fig1' }); + var inner = el('div', {}); + add(inner, el('div', { 'class': 'fig-label' }, 'Figure 1 · Where the findings sit')); + var k = el('div', { 'class': 'kary', id: 'kary' }); + add(k, U.karyogram(marks)); + add(inner, k); + add(inner, el('figcaption', {}, marks.length ? 'Each mark is one finding with a position, on its chromosome. Shape and colour give the level, as in the row above; every finding is also listed in its section.' + : 'No finding with a position yet. The findings without one (medicine genes, repeat counts, copy numbers) are listed in their sections.')); + add(fig, inner, el('aside', {})); + add(slot, fig); + } + + // --- the table of contents, the phone tab bar, the cover ------------------------------------------ + function drawToc() { + rail.textContent = ''; + if (!sectionsList().length) { return; } + add(rail, el('div', { 'class': 'group' }, running() ? 'The report, section by section' : 'In this report')); + var ol = el('ol', {}); + var n = counts(); + var lvOf = { 1: 'act', 2: 'medicine', 3: 'carrier', 4: 'review' }; + sectionsList().forEach(function (sec) { + var a = el('a', { href: '#s' + sec.n, 'data-toc': sec.n }); + add(a, el('span', { 'class': 'tn' }, String(sec.n)), el('span', {}, sec.title)); + var lv = lvOf[sec.n]; + if (lv && n[lv]) { add(a, U.level(lv, String(n[lv]), 'tc')); } + else if (sec.n === 5) { add(a, U.level('trust', '', 'tc' + (n.trust ? ' problem' : ''))); } + else if (sec.n === 8) { add(a, el('span', { 'class': 'tc muted' }, String(sec.cards.length))); } + if (running()) { var st = stamp(sec); add(a, el('span', { 'class': 'tsub ' + st.k }, st.t.toLowerCase())); } + add(ol, add(el('li', {}), a)); + }); + add(rail, ol); + add(rail, el('div', { 'class': 'group' }, 'How the run went')); + add(rail, add(el('div', { 'class': 'people' }), add(el('a', { href: '#progress' }), el('span', {}, 'Step timeline'), el('span', { 'class': 'muted' }, '→')))); + if (family) { + add(rail, el('div', { 'class': 'group' }, 'The family')); + var ppl = el('div', { 'class': 'people' }); + add(ppl, add(el('a', { href: family.url }), el('span', {}, 'Family report'), el('span', { 'class': 'muted' }, '→'))); + (family.data.state.members || []).forEach(function (m) { + if (m.sample === personName()) { return; } + var u = memberUrl(m.sample); + var a = el(u ? 'a' : 'span', u ? { href: u } : { 'class': 'muted' }); + add(a, el('span', { 'class': 'pc' + (m.sequenced ? '' : ' nogenome') }, m.sample)); + var r = family.data.runs && family.data.runs[m.sample]; + if (r && r.attention) { add(a, U.level('review', String(r.attention), 'tc')); } + add(ppl, a); + }); + add(rail, ppl); + } + } + function drawTabbar() { + var bar = document.getElementById('tabbar'); + if (!bar) { return; } + bar.textContent = ''; + var n = counts(); + [[1, 'To act', 'act'], [2, 'Medicines', 'medicine'], [3, 'Family', 'carrier'], [5, 'Trust', 'trust'], [8, 'More', null]].forEach(function (x) { + var a = el('a', { href: '#s' + x[0] }); + add(a, el('span', { 'class': 'ti' }, String(x[0]))); + add(a, el('span', {}, x[1] + (x[2] && n[x[2]] ? ' · ' + n[x[2]] : ''))); + add(bar, a); + }); + } function header() { var p = (results && results.person) || {}; - var slot = document.getElementById('avatar-slot'); var planned = ((state && state.steps) || []).filter(function (s) { return s.state !== 'not planned'; }); var done = planned.filter(function (s) { return s.state === 'done'; }).length; + var slot = document.getElementById('avatar-slot'); slot.textContent = ''; add(slot, U.avatar(p.inferred_sex || (state && state.run && state.run.declared_sex), true, planned.length ? done / planned.length : 0)); + var run = (state && state.run) || {}; + var kicker = document.getElementById('kicker'); + kicker.textContent = (running() ? 'Being written · ' : 'Personal genome report') + (family ? (running() ? '' : ' · ') + 'Family ' + family.name : ''); var line = document.getElementById('person-line'); line.textContent = ''; var bits = []; - if (p.inferred_sex) { bits.push('Sex from the reads: ' + p.inferred_sex + (p.declared_sex ? ', declared ' + p.declared_sex : '')); } - if (bits.length) { add(line, bits.join('. ') + '. '); } - var fam = state && state.plugins && state.plugins.family; + if (p.inferred_sex || p.declared_sex) { + bits.push(el('span', {}, (p.inferred_sex ? p.inferred_sex + ' from the reads' : 'sex not measured') + + (p.declared_sex ? (p.declared_sex === p.inferred_sex ? ', as declared' : ', declared ' + p.declared_sex) : ''))); + } + var r = role(); + if (r) { bits.push(el('span', {}, r)); } + if (run.state === 'finished') { + var hh = U.hours(run.started_utc, run.finished_utc); + bits.push(el('span', {}, 'finished' + (run.finished_utc ? ' ' + U.dm(run.finished_utc) : '') + (hh ? ' after ' + hh.toFixed(1) + ' h' : ''))); + } else if (running()) { + bits.push(el('span', {}, done + ' of ' + planned.length + ' steps done')); + } + bits.forEach(function (b) { add(line, b); }); + // Breadcrumb: people / family / person. var crumbs = document.getElementById('crumbs'); var name = document.getElementById('run-name'); + var home = document.getElementById('home-crumb'); crumbs.textContent = ''; - ((fam && fam.kind === 'member' && fam.families) || []).forEach(function (f, i) { - if (i === 0) { add(line, 'In family '); } else { add(line, ', '); } - add(line, el('a', { href: f.url }, f.name)); - add(crumbs, el('a', { href: f.url }, f.name), el('span', { 'class': 'sep', 'aria-hidden': 'true' }, '/')); + add(crumbs, home, el('span', { 'class': 'sep', 'aria-hidden': 'true' }, '/')); + var fam = state && state.plugins && state.plugins.family; + ((fam && fam.kind === 'member' && fam.families) || []).forEach(function (f) { + add(crumbs, el('a', { href: f.url }, 'Family ' + f.name), el('span', { 'class': 'sep', 'aria-hidden': 'true' }, '/')); }); add(crumbs, name); - // The strip: one cell per planned step, filled when done, striped while it runs. + // The strip under the timeline: one cell per planned step. var strip = document.getElementById('strip'); strip.textContent = ''; - planned.forEach(function (s) { - add(strip, el('span', { 'class': 'cell st-' + s.state.replace(/\s+/g, '-'), title: s.step + ' ' + s.label + ': ' + s.state })); - }); - var txt = document.getElementById('strip-text'); - var running = planned.filter(function (s) { return s.state === 'running'; }).length; + planned.forEach(function (s) { add(strip, el('span', { 'class': 'cell st-' + s.state.replace(/\s+/g, '-'), title: s.step + ' ' + s.label + ': ' + s.state })); }); + var nrun = planned.filter(function (s) { return s.state === 'running'; }).length; var failed = planned.filter(function (s) { return s.state === 'failed'; }).length; - var run = (state && state.run) || {}; var t = done + ' of ' + planned.length + ' steps done'; if (run.state === 'finished') { t = 'Finished' + (run.finished_utc ? ' ' + U.day(run.finished_utc) : '') + ': ' + t; } - else if (running) { t = 'Running: ' + t + ', ' + running + ' running now'; } + else if (nrun) { t = 'Running: ' + t + ', ' + nrun + ' running now'; } if (failed) { t += ', ' + failed + ' failed'; } var rc = state && state.plugins && state.plugins.recheck; if (rc && rc.checked_utc) { t += '. Databases re-checked ' + U.day(rc.checked_utc); } - txt.textContent = t + '.'; + document.getElementById('strip-text').textContent = t + '.'; + runHero(planned, done, nrun, failed); + root.setAttribute('data-pgp-run', run.state || ''); + } + // While the run goes: the hero (31 of 54), the timing and what runs now. + function runHero(planned, done, nrun, failed) { + var hero = document.getElementById('runhero'); + var badge = document.getElementById('live-badge'); + hero.hidden = !running(); + if (badge) { badge.hidden = !running(); } + var list = document.getElementById('running-list'); + var box = document.getElementById('running-now'); + list.textContent = ''; + var now = Date.parse((state && state.generated_utc) || ''); + planned.filter(function (s) { return s.state === 'running'; }).forEach(function (s) { + var li = el('li', {}); + var el2 = isFinite(now) && s.started_utc ? (now - Date.parse(s.started_utc)) / 60e3 : null; + var usual = s.hours ? s.hours * 60 : null; + add(li, add(el('div', { 'class': 'rn' }), el('span', {}, s.label), + el('span', {}, (el2 !== null ? Math.round(el2) + ' min' : '') + (usual ? ' of ~' + Math.round(usual) + ' min' : '')))); + if (el2 !== null && usual) { + var pb = el('div', { 'class': 'pb' }); + var i = el('i', {}); + i.style.setProperty('--p', Math.min(100, 100 * el2 / usual).toFixed(1) + '%'); + add(li, add(pb, i)); + } + add(list, li); + }); + box.hidden = !list.childNodes.length; + if (!running()) { return; } + var ops = state.plugins && state.plugins.ops; + // hours_left is the ops plugin's {low_s, high_s} range from the usual step times, or null. + var hl = ops && ops.hours_left; + var left = hl && typeof hl.high_s === 'number' ? hl.high_s / 3600 : null; + var low = hl && typeof hl.low_s === 'number' ? hl.low_s / 3600 : null; + var span = left === null ? null : (low !== null && Math.round(low) !== Math.round(left) ? Math.max(0, Math.round(low)) + '–' + Math.max(1, Math.round(left)) + ' h' : '~' + Math.max(1, Math.round(left)) + ' h'); + var retries = ops && ops.memory ? ops.memory.retries || 0 : 0; + var alerts = (ops && ops.alerts) || []; + var lede = document.getElementById('run-lede'); + lede.textContent = ''; + add(lede, 'This report is being written as the steps finish, each section in its place. ', + el('b', {}, done + ' of ' + planned.length + ' are done'), + span !== null ? ', about ' + span.replace(' h', ' hours') + ' remain. ' : '. ', + failed ? el('b', {}, failed + ' failed: the timeline says which. ') : '', + alerts.length ? alerts.map(function (a) { return a.text; }).join('; ') + '. ' : (failed ? '' : 'Nothing needs you. '), + findingsNow().length ? 'The first findings are already in.' : 'No section is written yet.'); + var big = document.getElementById('bignum'); + big.textContent = String(done); + add(big, el('small', {}, 'of ' + planned.length + ' steps')); + var bar = document.getElementById('stepbar'); + bar.textContent = ''; + [['d', done], ['r', nrun], ['f', failed]].forEach(function (x) { + if (!x[1]) { return; } + var i = el('i', { 'class': x[0] }); + i.style.setProperty('width', (100 * x[1] / Math.max(1, planned.length)).toFixed(2) + '%'); + add(bar, i); + }); + var key = document.getElementById('stepkey'); + key.textContent = ''; + add(key, el('span', {}, done + ' done'), el('span', {}, nrun + ' running'), + el('span', {}, (planned.length - done - nrun - failed) + ' waiting'), el('span', {}, failed + ' failed')); + var timing = document.getElementById('timing'); + timing.textContent = ''; + var started = state.run.started_utc || (planned.map(function (s) { return s.started_utc; }).filter(Boolean).sort()[0]); + var el3 = U.hours(started, state.generated_utc); + [['Elapsed', el3 !== null ? el3.toFixed(1) + ' h' : 'not known', started ? 'since ' + U.hm(started) : ''], + ['To go', span !== null ? span : 'not known', span !== null ? 'from usual step times' : 'no usual times yet'], + ['Retries', String(retries), retries ? 'recovered unless a step failed' : 'none']].forEach(function (x) { + add(timing, add(el('div', {}), el('span', { 'class': 'k' }, x[0]), el('span', { 'class': 'v' }, x[1]), el('span', { 'class': 'r' }, x[2]))); + }); + } + + // --- the route: #s/, #s, #f/, #q/, #progress ----------------------------- + var CHAPTER_SECTION = { quality: 5, clinical: 8, carrier: 3, medicines: 2, ancestry: 6, traits: 7, other: 10, look: 1, leaves: 9 }; + function route() { + var h; + try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return; } + if (!h) { return; } + var target = null, isCard = false; + if (h.indexOf('s/') === 0 || (h && !/^(s\d+|f\/.*|q\/.*|progress)$/.test(h) && cardById(h))) { + target = document.getElementById('card-' + (h.indexOf('s/') === 0 ? h.slice(2) : h)); + isCard = true; + } else if (h.indexOf('f/') === 0) { target = document.getElementById('f-' + h.slice(2)); } + else if (h.indexOf('q/') === 0) { target = document.getElementById('s' + (CHAPTER_SECTION[h.slice(2)] || 1)); } + else { target = document.getElementById(h); } + if (!target) { return; } + var d = target.closest && target.closest('details'); + if (d) { d.open = true; } + if (isCard) { + Array.prototype.forEach.call(panel.querySelectorAll('.result.target'), function (x) { x.classList.remove('target'); }); + target.classList.add('target'); + } + target.scrollIntoView({ block: 'start' }); + if (target.focus) { target.focus({ preventScroll: true }); } + Array.prototype.forEach.call(rail.querySelectorAll('[data-toc]'), function (a) { + var sec = target.closest ? target.closest('section.sec') : null; + a.setAttribute('aria-current', sec && sec.id === 's' + a.getAttribute('data-toc') ? 'true' : 'false'); + }); } + window.addEventListener('hashchange', route); // --- data --------------------------------------------------------------------------------------- function load() { @@ -495,27 +844,45 @@ fetchedFor = key; U.getJSON('results.json').then(function (r) { if (key !== fetchedFor) { return; } - if (!(r && (r.chapters || r.note))) { fetchedFor = null; return; } // tried again on the next state + if (!(r && (r.chapters || r.note))) { fetchedFor = null; return; } results = r; redraw(true); + route(); }).catch(function () { if (key === fetchedFor) { fetchedFor = null; } }); } function redraw(force) { - var k = JSON.stringify([doneSet(), state && state.run && state.run.state, !!results, - state && state.plugins && state.plugins.recheck && state.plugins.recheck.mtime]); - if (!force && k === drawnKey) { return; } + var k = JSON.stringify([doneSet(), state && state.run && state.run.state, !!results, !!family, Object.keys(shown), + state && state.plugins && state.plugins.recheck && state.plugins.recheck.mtime, + ((state && state.steps) || []).filter(function (s) { return s.state === 'running'; }).map(function (s) { return s.step; })]); + if (!force && k === drawnKey) { header(); return; } drawnKey = k; + var y = window.scrollY; + var open = Array.prototype.map.call(panel.querySelectorAll('details[open]'), function (d) { + return d.getAttribute('data-card') || (d.closest('[id]') || {}).id; }); header(); - var keep = document.activeElement && rail.contains(document.activeElement) ? document.activeElement.getAttribute('data-tab') : null; - drawRail(); - if (!chosen && !parse()) { show(defaultTab()); route(false); } else { route(false); } - if (keep) { var t = rail.querySelector('[data-tab="' + keep + '"]'); if (t) { t.focus(); } } + drawToc(); + drawTabbar(); + drawLead(); + drawSections(); + open.forEach(function (id) { + var d = panel.querySelector('details[data-card="' + id + '"]'); + if (d) { d.open = true; } + }); + window.scrollTo(0, y); + document.getElementById('progress-q').textContent = running() + ? 'Is it moving, when will it end, is anything wrong? One line per step.' : 'One line per step: when it ran, and what it found.'; + // While the run goes the timeline leads the page; once it ends it closes the report. + var content = document.getElementById('content'); + var prog = document.getElementById('progress'); + if (running() && content.firstElementChild !== prog) { content.insertBefore(prog, content.firstElementChild); } + else if (!running() && content.lastElementChild !== prog) { content.appendChild(prog); } } window.PGP.register({ name: 'person', onState: function (s) { state = s; + loadFamily(); if (!replay || !results) { load(); } redraw(false); } diff --git a/tests/demo/build_live_demo.py b/tests/demo/build_live_demo.py index 51b2eb0b..a7b0e01e 100644 --- a/tests/demo/build_live_demo.py +++ b/tests/demo/build_live_demo.py @@ -13,6 +13,8 @@ runs/DEMO-001/results.json the person's results (bin/live_results.py), as the results plugin serves them at the end of the run; each card shows once the replay reaches the end of its steps + runs/DEMO-001/finding// one page per finding (assets/live/finding.html), as the + results plugin's finding route serves it runs/DEMO-001/... the files the plugins point at (the charts, the search index, the re-check), the report as report.html and the files it links to @@ -363,6 +365,16 @@ def person_site(out, root, name, plugin_js, frames, results, autoplay=False): if os.path.isfile(p): os.makedirs(os.path.dirname(os.path.join(sd, dst)), exist_ok=True) shutil.copy(p, os.path.join(sd, dst)) + # One page per finding, as the server's /runs//finding// route serves it. + with open(os.path.join(ASSETS, "finding.html")) as f: + tpl = f.read() + for fd in results.get("findings") or []: + fdir = os.path.join(sd, "finding", fd["id"]) + os.makedirs(fdir) + with open(os.path.join(fdir, "index.html"), "w") as f: + f.write(with_csp(tpl.replace("{{assets}}", "../../../../assets").replace("{{run}}", html.escape(name)) + .replace("{{finding}}", html.escape(fd["id"])).replace("{{mode}}", "replay") + .replace('href="/"', 'href="../../../../"'))) os.makedirs(os.path.join(sd, "viewer")) with open(os.path.join(sd, "viewer", "index.html"), "w") as f: f.write(plain_page("Genome viewer: not in the demo", @@ -375,7 +387,7 @@ def write_site(out, root, frames, fam): if os.path.exists(out): shutil.rmtree(out) shutil.copytree(ASSETS, os.path.join(out, "assets"), - ignore=shutil.ignore_patterns("index.html", "login.html", "home.html", "family.html")) + ignore=shutil.ignore_patterns(*owngenome_live.PAGES)) plugin_dir = os.path.join(ASSETS, "plugins") plugin_js = sorted(f for f in os.listdir(plugin_dir) if owngenome_live.ASSET_RE.match(f) and f.endswith(".js")) \ if os.path.isdir(plugin_dir) else [] diff --git a/tests/demo/render_pictures.py b/tests/demo/render_pictures.py index 795eccc1..acb72a2d 100755 --- a/tests/demo/render_pictures.py +++ b/tests/demo/render_pictures.py @@ -123,7 +123,7 @@ def live_picture(out): # all 'self', so the picture is the same. page = browser.new_page(viewport={"width": 1280, "height": 1000}, device_scale_factor=1, color_scheme="dark", bypass_csp=True) - page.goto(f"http://127.0.0.1:{srv.server_address[1]}/runs/{SAMPLE}/#q/clinical") + page.goto(f"http://127.0.0.1:{srv.server_address[1]}/runs/{SAMPLE}/") page.wait_for_function("document.documentElement.dataset.pgpStatus === 'ready'") # The last moment: the run ended, the charts are drawn, the re-check is in. page.evaluate("() => { const s = document.getElementById('replay-slider'); s.value = s.max; " diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index e3604f9b..67ff83e8 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -21,16 +21,18 @@ static host serves it (no policy header): the home page's people and family, the replay to its last frame, the charts, a search, the re-check banner, the run-health card, the family link and its pedigree, the viewer - page, and no policy violation; the person page's cards (the doctor tab - with the ClinVar card, its caveat and its chart), the tab row moved with - the arrow keys, #s/clinvar focusing its card; the family page with a + page, and no policy violation; the person page's ten sections, its + contents, lead and ledger, #s/clinvar focusing the ClinVar card with its + caveat, chart and flag, a finding's own page with its plan and evidence; + the family page with a clickable pedigree and no step map or report link; the widgets of the partner's and the son's pages (the repeat gauge, the Lp(a) interval, the CHIP VAF, the copy-number dots, the Y path) and the AnnotSV and MoChA marks on their genome charts; the family page's relatedness grid, the son's painted stretches (one mark per row of his segments file), his de novo and compound-het tables and the members side by side; and at a phone's - width (390 px) no page scrolls sideways. + width (390 px) no page scrolls sideways (every section open, the finding + page too) and the person page has its bottom tab bar. Checks 1, 2, 3, 5, the widgets (person.js without its widget line), the family depth (family.json with the son's stretches emptied) and the phone width also run against a planted mistake and must fail there (2: a build with one plugin's state hook dropped): a check that cannot fail is no check. @@ -280,21 +282,31 @@ def browser_checks(out): def person_checks(b, base): - """The person page at the end of the run: cards, keyboard, a section link; the family page's own view.""" - b.eval("location.hash = '#q/clinical'; true") - n = b.wait("document.documentElement.dataset.pgpTab === 'clinical' && document.documentElement.dataset.pgpResults") - card = b.eval("var c = document.getElementById('card-clinvar'); c && [!!c.querySelector('.caveat .caveat-lead'), " - "!!c.querySelector('figure[data-chart]'), c.classList.contains('attn')].join(' ')") - check("browser: the doctor tab shows its cards; ClinVar's has its caveat, its chart and its flag", - n and int(n) > 3 and card == "true true true", (n, card)) - b.eval("var t = document.querySelector('#rail [aria-selected=true]'); t.focus(); " - "t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); true") - tab = b.wait("document.documentElement.dataset.pgpTab !== 'clinical' && document.documentElement.dataset.pgpTab") - check("browser: the arrow key moves to the next chapter", tab == "carrier", tab) + """The person page at the end of the run: the ten sections, the lead, the contents, a section link, a finding + page; the family page's own view.""" + got = b.wait("document.documentElement.dataset.pgpResults && JSON.stringify([" + "document.querySelectorAll('main section.sec[data-journal]').length, " + "document.querySelectorAll('#rail [data-toc]').length, " + "(document.querySelector('#lead-slot .lede') || {textContent: ''}).textContent.length > 40, " + "document.querySelectorAll('#lead-slot .ledger a[data-level]').length])") + check("browser: the person page has its ten sections, the contents linking each, a lead paragraph and the ledger", + got == "[10,10,true,5]", got) b.eval("location.hash = '#s/clinvar'; true") - focus = b.wait("document.documentElement.dataset.pgpTab === 'clinical' && document.activeElement && " - "document.activeElement.id") - check("browser: #s/clinvar opens the doctor tab and focuses the ClinVar card", focus == "card-clinvar", focus) + focus = b.wait("document.activeElement && document.activeElement.id === 'card-clinvar' && 'card-clinvar'") + card = b.eval("var c = document.getElementById('card-clinvar'); c && [!!c.querySelector('.caveat .caveat-lead'), " + "!!c.querySelector('figure[data-chart]'), c.classList.contains('attn'), " + "c.getBoundingClientRect().height > 0].join(' ')") + check("browser: #s/clinvar focuses the ClinVar card, shown, with its caveat, its chart and its flag", + focus == "card-clinvar" and card == "true true true true", (focus, card)) + link = b.eval("var a = document.querySelector('[data-finding] h3 a[href^=\"finding/\"]'); a && a.getAttribute('href')") + check("browser: a finding links its own page", bool(link), link) + if link: + b.go(f"{base}/runs/{S}/{link}") + page = b.wait("document.documentElement.dataset.pgpStatus === 'ready' && JSON.stringify([" + "!!document.querySelector('h1.h-display'), document.querySelectorAll('#plan li').length > 0, " + "!!document.getElementById('evidence'), document.documentElement.dataset.pgpBlocked || ''])") + check("browser: the finding page shows its title, its plan and its evidence, and the policy blocks nothing", + page == '[true,true,true,""]', (link, page)) b.go(f"{base}/runs/{FAM}/") fam = b.wait("document.documentElement.dataset.pgpStatus === 'ready' && [!document.getElementById('map'), " "!document.getElementById('report-link'), (document.querySelector('svg.pedigree-svg a') || {" @@ -303,24 +315,23 @@ def person_checks(b, base): fam == f"true true ../../runs/{S}/", fam) -# The widgets: (person, tab, kind, the marks it must draw) on the demo's other people. -WIDGETS = [("DEMO-002", "clinical", "str", "[data-widget=str] .w-row", 8), - ("DEMO-002", "clinical", "ci", "[data-widget=ci] .w-tick", 1), - ("DEMO-002", "clinical", "vaf", "[data-widget=vaf] .w-tick.w-call", 1), - ("DEMO-003", "carrier", "copies", "[data-widget=copies] li", 5), - ("DEMO-003", "ancestry", "ypath", "[data-widget=ypath] li", 5), - ("DEMO-002", "clinical", "MoChA bar", "figure[data-chart=genome] rect.mca", 1), - ("DEMO-003", "clinical", "AnnotSV span", "figure[data-chart=genome] rect.sv", 1)] +# The widgets: (person, card, kind, the marks it must draw) on the demo's other people. +WIDGETS = [("DEMO-002", "expansions", "str", "[data-widget=str] .w-row", 8), + ("DEMO-002", "lpa", "ci", "[data-widget=ci] .w-tick", 1), + ("DEMO-002", "chip", "vaf", "[data-widget=vaf] .w-tick.w-call", 1), + ("DEMO-003", "paralogs", "copies", "[data-widget=copies] li", 5), + ("DEMO-003", "y-chromosome", "ypath", "[data-widget=ypath] li", 5), + ("DEMO-002", "clinvar", "MoChA bar", "figure[data-chart=genome] rect.mca", 1), + ("DEMO-003", "clinvar", "AnnotSV span", "figure[data-chart=genome] rect.sv", 1)] def widget_counts(b, base): got = {} - for person, tab, kind, sel, _ in WIDGETS: + for person, cid, kind, sel, _ in WIDGETS: b.go("about:blank") # a new document each time, so the wait never reads the page before b.wait("location.href === 'about:blank'") - b.go(f"{base}/runs/{person}/#q/{tab}") - b.wait("document.documentElement.dataset.pgpResults && document.documentElement.dataset.pgpTab === %s" - % json.dumps(tab)) + b.go(f"{base}/runs/{person}/#s/{cid}") + b.wait("document.documentElement.dataset.pgpResults && document.getElementById(%s)" % json.dumps("card-" + cid)) if kind in ("MoChA bar", "AnnotSV span"): b.wait("document.documentElement.dataset.pgpCharts") time.sleep(0.5) @@ -401,9 +412,11 @@ def phone_checks(b, base): try: wide = {} for name, path, ready in (("home", "/", "document.documentElement.dataset.pgpStatus === 'ready'"), - ("person", f"/runs/{S}/#q/clinical", "document.documentElement.dataset.pgpResults"), + ("person", f"/runs/{S}/", "document.documentElement.dataset.pgpResults"), ("family", f"/runs/{FAM}/", "document.documentElement.dataset.pgpStatus === 'ready'"), - ("partner", f"/runs/{OTHERS[0]}/#q/clinical", "document.documentElement.dataset.pgpResults")): + ("partner", f"/runs/{OTHERS[0]}/", "document.documentElement.dataset.pgpResults"), + ("finding", f"/runs/{OTHERS[0]}/finding/str-fmr1/", + "document.documentElement.dataset.pgpStatus === 'ready'")): b.go(base + path) b.wait(ready) if name == "person": @@ -411,15 +424,12 @@ def phone_checks(b, base): "s.dispatchEvent(new Event('input')); true") b.wait("document.documentElement.dataset.pgpFrame === document.getElementById('replay-slider').max") time.sleep(1) - # Every tab of the person page, not only the one the link opens. - tabs = b.eval("Array.prototype.map.call(document.querySelectorAll('[role=tab][data-tab]'), " - "function (t) { return t.getAttribute('data-tab'); }).join(' ')") or "" - for tab in tabs.split(): - b.eval(f"document.querySelector('[role=tab][data-tab=\"{tab}\"]').click(); true") - time.sleep(0.3) - if too_wide(b): - wide[f"person {tab}"] = too_wide(b) - wide["person tabs"] = "" if len(tabs.split()) >= 3 else f"only {tabs!r}" + # Every section open: the cards folded into section 8 too. + b.eval("Array.prototype.forEach.call(document.querySelectorAll('main details'), " + "function (d) { d.open = true; }); true") + time.sleep(0.3) + tabs = b.eval("getComputedStyle(document.getElementById('tabbar')).display") + wide["person tab bar"] = "" if tabs == "grid" else f"tab bar display {tabs!r}" wide[name] = too_wide(b) check("browser: at 390 px no page scrolls sideways", not any(wide.values()), wide) b.eval("var d = document.createElement('div'); d.style.width = '900px'; d.style.height = '4px'; " From 6f6b6a729a96f72794ec6c881d863e5ed9f12fea Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:07:47 +0100 Subject: [PATCH 13/20] docs(live): describe the Journal pages, the finding route and the new JSON fields Bead: pgp-5bja.31 --- README.md | 2 +- docs/images/demo-live-site.png | Bin 125464 -> 214514 bytes docs/live-site.md | 47 ++++++++++++++++++++------------- docs/recheck.md | 2 +- scripts/ci/e2e-case-paths.tsv | 2 +- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index aa77ab96..df90cf70 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The full documentation is at **https://geiserx.github.io/OwnGenome/**, one page - [Vendor compatibility guide](https://geiserx.github.io/OwnGenome/vendor-guide/): what each provider delivers and how to get it - [Pipeline overview](https://geiserx.github.io/OwnGenome/pipeline-overview/): every step with its tool and image, which ones a default run includes, and the page for each step - [Nextflow](https://geiserx.github.io/OwnGenome/nextflow/): the workflow runner, parallel steps and resume -- [Watch a run live](https://geiserx.github.io/OwnGenome/live-site/): a results page per person, laid out by the report's chapters, a page per family, and the step map in a Progress tab while the run goes, behind a password. The [demo](https://geiserx.github.io/OwnGenome/demo/) replays an invented run +- [Watch a run live](https://geiserx.github.io/OwnGenome/live-site/): each genome as a report that leads with what to take to a doctor, a page per finding, a report per family, and a timeline of the steps while the run goes, behind a password. The [demo](https://geiserx.github.io/OwnGenome/demo/) replays an invented run - [Interpreting your results](https://geiserx.github.io/OwnGenome/interpreting-results/): what each report means and what to do with it - [Multi-sample comparison](https://geiserx.github.io/OwnGenome/multi-sample/): partners, siblings, parents - [Long-read guide](https://geiserx.github.io/OwnGenome/long-read-guide/): Nanopore and PacBio HiFi diff --git a/docs/images/demo-live-site.png b/docs/images/demo-live-site.png index db28216f70c268b60cc31d705c41be60e1feba7d..0cba14cba39eb2cd718f7c349f91498bef1069ba 100644 GIT binary patch literal 214514 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 literal 125464 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 F_+N5T&1?Vw diff --git a/docs/live-site.md b/docs/live-site.md index b92642f8..fa266b04 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -1,6 +1,6 @@ # Watch a Run Live -A full run takes a day or more. The live site shows each person's results as their steps finish, in a browser on any device that can reach the machine: the report's chapters as tabs, one card per section with its numbers, its rows and its caveat, and what is worth a look first. A Progress tab keeps the step map. A home page lists everyone by family, and each family has a page of its own. The pages repaint as messages arrive; nobody has to reload them. +A full run takes a day or more. The live site shows each person's results as their steps finish, in a browser on any device that can reach the machine. Each genome reads as a report: a lead paragraph that says what to take to a doctor, what to tell a prescriber and what is worth a look, then ten numbered sections from what matters most to what the run cannot see, each finding with a page of its own. While the run goes, a timeline of its steps sits on top. A home page lists everyone by family, and each family has a report of its own. The pages repaint as messages arrive; nobody has to reload them. `bin/owngenome_live.py` is the server. It uses Python's standard library only, so the `python3` on the host is enough, and it has its own password, access log and limits, so it needs no web server in front of it. @@ -8,7 +8,7 @@ A full run takes a day or more. The live site shows each person's results as the https://geiserx.github.io/OwnGenome/demo/ is the live site on DEMO-001, the invented sample of the docs pictures. It replays a made-up run from the first task to the database re-check after it: press Play, or drag the slider, and watch the cards fill in as their steps end. Every number, result and family member in it is made up. The family is an invented trio: DEMO-001, the partner DEMO-002 and their son DEMO-003. Each of the three has a finished run with its own findings, so every card widget and the whole family page have something to draw: DEMO-002 carries an FMR1 premutation-range count, a CHIP call and a MoChA loss; DEMO-003 a class 4 AnnotSV deletion and a Y path. -![The person page of DEMO-001 at the end of the demo run: the name with a progress strip of 54 finished steps, a tab column (Look first, Trust, Doctor, Child, Medicines, Ancestors, Traits, Other, Leaves out, Progress) with Doctor open, and the ClinVar card marked worth a look and newer ClinVar available, with its counts and the chromosome chart of its hits](images/demo-live-site.png){ loading=lazy } +![The person page of DEMO-001 at the end of the demo run: the name with its family and run state, the contents of the ten sections with a count per level, the lead paragraph (no finding for a doctor, two medicine results, six worth a look), the five-level ledger, the data-quality line and the start of the chromosome map of the findings](images/demo-live-site.png){ loading=lazy } The pages and their plugins are the files of `assets/live/`, unchanged. Each moment of the replay is the server's own `state.json` at that moment: `tests/demo/build_live_demo.py` loads every plugin, stores the made-up messages of `tests/fixtures/live/demo-001.weblog.jsonl` one by one as the ingest port would, and reads the state after each. The cards come from the `results.json` the server would give at the end of the run; each shows once the replay reaches the end of its step. The home page, the charts, the search box, the run-health card, the database re-check and the family page work as they do on your own site. The genome viewer does not: it opens your own reads on your own computer, so its links lead to a page that says it is not in the demo. @@ -44,26 +44,35 @@ weblog { ## What the pages show -**Home** (`/`). Everyone, grouped by family, then the runs in no family. A card shows the pedigree symbol with a ring for the run's progress, the person's place in the family, the steps done and how many cards the report flags. Runs still going come first in each family. A family member with no run on this server is listed, dashed. Without scripts the page is a plain list of runs. +Every result carries one of six levels, with a word, a shape and a colour, so the shape alone tells them apart: **Take to a doctor** (◆), **Tell your prescriber** (a pill), **Worth a look** (▲), **For family planning** (two rings), **Data quality** (✓, red when the data has a problem) and **For information** (○). `bin/live_results.py` sets the level from the rows the report already prints: an ACMG secondary finding, a pathogenic CPSR row or two hits in a carrier-list gene is to act on; a ClinVar hit, an AnnotSV class 4, a repeat outside Stranger's normal, a CHIP or MoChA call or an Exomiser top gene is worth a look (a full-mutation repeat, an AnnotSV class 5 and a positive VNtyper call are to act on); a carrier-list row, a gene both partners carry and one SMN1 copy are for family planning; a CPIC gene with changed function is for the prescriber; a sex mismatch, a contamination warning or a second run of the same person is data quality. Everything else is for information. No page says a section is fine: a section with no finding says "No finding in what these steps assessed" and what it looked at. -**A person** (`/runs//`). The name, the declared and measured sex, the families they are in, and a strip with one cell per planned step. Below, one tab per chapter of the report, each named by its question: +**Home** (`/`). A date line, then one sentence on where everyone stands (how many reports are finished or being written, and whether any finding needs a doctor), and beside it the findings that need attention across everyone, one line per person and level, each opening its finding page. A run still going comes first, as "Being written", with its steps done and the sections already written. Then each family: a small pedigree in the members' colours and one tile per person, with the first lines of their report's lead and their counts by level. A family member with no run on this server is listed, dashed. Last, every run as a log: state, steps, hours, when it ended. Without scripts the page is a plain list of runs. -- **Look first** opens by default once a step has finished: the cards the report itself flags, in chapter order, each with its headline. A card not listed is for information; its caveats still apply. -- **Trust, Doctor, Child, Medicines, Ancestors, Traits, Other** follow the report's seven chapters. One card per section, with the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts. Under each card sits its caveat: the first sentence, then the rest behind "Full caveat". A card is marked "worth a look" (▲) only where the report or the step headline raises a flag, and "for information" (○) otherwise; no card says a section is fine. A card whose step has not ended says so; one whose step failed or is older than its input says that. A newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. -- **Leaves out** lists what the run does not assess. -- **Progress** is the step map, a step's detail, Found so far, the run-health card and the database re-check, as before. +**A person** (`/runs//`). The name, the sex the reads show against the declared one, the person's place in the family and the run state. Down the left, the contents: the ten sections with how many findings each level has, then the step timeline and the family. The page opens on the lead: a paragraph built from the findings (data-quality problems first, then the findings for a doctor named one by one, the medicine genes, the carrier genes, what is worth a look, and the mean depth), the ledger of the five levels with a count each, the data-quality line, and Figure 1, the chromosomes with each finding at its place. Then the sections: -Each card links to its section of the report and to the files it names. The header search finds a gene or rsID across the results and opens the card that names it. `#q/` opens a tab, `#s/

` opens a card (the report's own anchors), `#progress` the step map. +1. **To act on**, 2. **Medicines**, 3. **For your family**, 4. **Worth a look**, 5. **Can I trust this?**: each finding of the level, with what it is, what to do, and in the margin its evidence, its caveat and its link to the report; then the cards of the report's sections that belong there. +6. **Origins** and 7. **Traits and scores**: the ancestry and trait cards. +8. **Assessed, no finding**: one line per section that ran and raised nothing, each opening its full card. +9. **What it cannot see**: what the run does not assess and the steps that did not run. +10. **Sources and files**: the database versions, the database re-check, and the report and its files. -- **The step map.** One box per step the run planned, drawn from `assets/steps.tsv`: a step sits right of the steps whose output it reads. The colour is the step's state from `bin/run_state.py` ([Where a Run Stands](run-state.md)): waiting, running, done, failed, skipped, not finished. Skipped steps stay on the map, greyed. -- **A step's detail.** Click a box: its state and reason, start and end, the usual hours, each process with its tasks waiting, running, done, from the cache or failed, and the headline of its sections with a link into the report. -- **Found so far.** One line per finished section, worded by the same code that writes the report, caveat included. No line ever says a section is fine. The ClinVar line, with no hit, is information, never an all-clear. +A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose. + +While the run goes, the page opens on the run instead: a sentence on how far it is ("23 of 27 are done, about 8–13 hours remain"), the steps done as a big number and a bar, the time elapsed, the time to go from the usual step times, the retries, and the tasks running now against their usual time. Each section in the contents says whether it is written, being written or waiting. + +- **How the run went.** One line per step the run planned, on a time axis: a bar from its start to its end, or to now while it runs, with an outline to its usual hours. The colour is the step's state from `bin/run_state.py` ([Where a Run Stands](run-state.md)): waiting, running, done, failed, skipped, not finished. Beside it the tasks running now, a step's detail and the wire of what was found so far. +- **A step's detail.** Click a step: its state and reason, start and end, the usual hours, each process with its tasks waiting, running, done, from the cache or failed, and the headline of its sections with a link into the report. +- **Found so far.** One line per finished section, worded by the same code that writes the report, caveat included. The ClinVar line, with no hit, is information, never a clean result. - **The report.** Once written, `report.html` opens the run's `_report.html`, and the files it links to beside it. -- **The genome viewer.** `/runs//viewer/` shows the run's reads and variants in igv.js, as [`scripts/owngenome-view.sh`](viewer.md) does on one computer. The report's Viewer links and the charts' "See the reads" links open it at their position. +- **The genome viewer.** `/runs//viewer/` shows the run's reads and variants in igv.js, as [`scripts/owngenome-view.sh`](viewer.md) does on one computer. The report's Viewer links, the charts' "See the reads" links and a finding page's reads open it at their position. + +The header search finds a gene or rsID across the results and opens the card that names it. `#s` opens a section, `#s/
` a card (the report's own anchors), `#f/` a finding, `#q/` the section a report chapter falls in, `#progress` the timeline. + +**A finding** (`/runs//finding//`). One finding on a page of its own: its level and how many others share it, the title, the gene and variant, a plain explanation, and **What to do** as numbered steps. Then the evidence (its numbers, how common the change is on a log scale from one in a million to everyone, the caveat in the margin), the reads with a link to the genome viewer, the rest of the family, the related findings of the same gene or step, and the section of the report it comes from. The contents on the left lead back to the report and on to the next finding. The page is 404 unless the id is a finding of that run's `results.json` now. -**A family** (`/runs//`). The pedigree, drawn from `members.tsv` or `pedigree.tsv`, where each sequenced person with a run here opens their page and shows how many cards their report flags. Then the members, the analyses step 38 planned, each couple's carrier screen in full (genes where both carry a finding, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report, who is related how (step 38's relationship check beside the shared segments, as a grid of every pair, with ⚑ where the measured relationship differs from the declared one), and each child: the stretches inherited from each parent painted per chromosome (coloured by grandparent where step 38 could tell), the de novo candidates and the compound-het pairs, in trans first. Last, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and blood groups, one column each. On a phone the grid becomes a list of pairs. +**A family** (`/runs//`). A report in five chapters, opened by a lead: how many genomes are read together, whether the couple shares a carrier gene, whether the measured relationships match the tree, and how many children. I, the pedigree, drawn from `members.tsv` or `pedigree.tsv` in the members' colours, where each sequenced person with a run here opens their page and shows how many cards their report flags, beside the members and the analyses step 38 planned. II, who is related how: step 38's relationship check beside the shared segments, as a grid of every pair, with ⚑ where the measured relationship differs from the declared one. III, each couple's carrier screen in full (genes where both carry a finding, drawn as four children with the chance for each, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report. IV, each child: the stretches inherited from each parent painted per chromosome (coloured by grandparent where step 38 could tell), the de novo candidates and the compound-het pairs, in trans first. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and blood groups, one column each. On a phone the grid becomes a list of pairs. -On a phone the tab column becomes a row that scrolls sideways and each table row becomes a short list of label and value. The pages follow the browser's dark or light setting. +On a phone each page is one column: the contents give way to a tab bar at the bottom (To act, Medicines, Family, Trust, More), and each table row becomes a short list of label and value. The pages are dark. ## The genome viewer @@ -73,9 +82,9 @@ Per run it serves the folder's BAM or CRAM, its VCF and its SV VCF, each with it The reads cross the network to the browser that logged in: only the byte ranges igv.js asks for, but they are the sample's reads. Off loopback, use TLS (`--tls-cert` and `--tls-key`, or a proxy that ends TLS); without it the password and the reads cross the network in clear. Opening the viewer writes one access-log row; the byte ranges igv.js reads write none. -`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. +`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. A run with results also has `levels` (its findings counted by level), `top` (up to four findings to show first: id, level, title) and `lead` (its lead paragraph as plain text). -The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. +The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. Each card also has its `level`; the file adds `findings` (each with its id, level, section, title, plain text, gene, evidence, caveat, plan and related findings), `levels` (the counts), `journal` (the ten sections and the cards in each), `lead` (the lead paragraph as text pieces, the bold ones marked), `level_words` and `databases`. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. ## Privacy and what is stored @@ -167,8 +176,8 @@ WantedBy=multi-user.target The core files (`bin/owngenome_live.py`, `assets/live/index.html`, `home.html`, `family.html`, `core.js`, `live.js`, `person.js`, `home.js`, `family-page.js`, `live.css`, `login.html`) are meant to stay small. A new view adds two files instead of editing them: - `bin/owngenome_live_.py` with `register(app)`. At start the server imports every such file, sorted, and calls it. `app.route(method, pattern, fn, auth=True)` adds a page or an endpoint (behind the login unless `auth=False`), `app.run_file(glob)` lets the browser fetch more files of a run folder, `app.report_filter(fn)` rewrites the served report, `app.state_hook(name, fn)` adds `plugins[name]` to `state.json`, `app.run_kind(fn)` lists more runs, `app.add_argument(...)` adds a flag, `app.log(...)` writes an access-log row, and `app.config` holds the parsed flags. -- `assets/live/plugins/.js`. The run page loads every script in that folder after `live.js`; it calls `window.PGP.register({name, onState, onEvent, panel})`. `onState(state)` gets each new `state.json`, `onEvent(message)` each message from the stream, and `panel(element)` gets a card of its own in the Progress tab, or in the element whose id `slot` names (the search box sits in the header this way). A family's page loads the same scripts without `live.js`, so a plugin returns early when `window.PGP` is missing. +- `assets/live/plugins/.js`. The run page loads every script in that folder after `live.js`; it calls `window.PGP.register({name, onState, onEvent, panel})`. `onState(state)` gets each new `state.json`, `onEvent(message)` each message from the stream, and `panel(element)` gets a card of its own beside the run timeline, or in the element whose id `slot` names (the search box sits in the header this way). A family's page loads the same scripts without `live.js`, so a plugin returns early when `window.PGP` is missing. The page has a replay mode for a demo with no server: `` reads `frames.json` (`{"interval_ms": 1000, "frames": [{"t": ..., "state": }]}`) and plays it with a play button and a slider, through the same drawing code. -`tests/test_owngenome_live.py` checks the server, the login, the stored messages, the plugin interface and the page in a headless Chrome; `tests/test_live_results.py` checks `results.json` (every card's caveat word for word, no wording that reads as an all-clear, `results.json` behind the login); `tests/test_live_demo.py` builds the demo and checks every page, phone width included; `tests/test_live_post.sh` and `tests/fake-docker/run-all-live.sh` check what `run-all.sh` posts. +`tests/test_owngenome_live.py` checks the server, the login, the stored messages, the plugin interface and the page in a headless Chrome; `tests/test_live_results.py` checks `results.json` (every card's caveat word for word, no wording that reads as an all-clear, `results.json` behind the login); `tests/test_live_journal.py` checks the levels, the lead paragraph and the finding page's route; `tests/test_live_demo.py` builds the demo and checks every page, phone width included; `tests/test_live_post.sh` and `tests/fake-docker/run-all-live.sh` check what `run-all.sh` posts. diff --git a/docs/recheck.md b/docs/recheck.md index a9c8de84..4fef29fc 100644 --- a/docs/recheck.md +++ b/docs/recheck.md @@ -91,7 +91,7 @@ python3 bin/owngenome_recheck.py --launch-dir /path/to/launch/dir ## In the reports and on the live site -The next reports read `recheck/state.json`: both show one "Database re-check" line in the run facts. A check older than the sample's `run_manifest.tsv` describes an earlier run, so the reports leave it out until the next check. When the sample has a previous run in `history/` ([the delta report](delta-report.md)), a section whose results changed since it carries a badge such as "New since 2026-10-01T09:15:00Z", in both reports, and the delta report heads its changes the same way. The live site's re-check plugin (`bin/owngenome_live_recheck.py`, `assets/live/plugins/recheck.js`) shows the same line as a banner, puts a badge (↻) such as "newer ClinVar available" in the header of every result card of a named step on the person page, and marks each named step on the Progress tab's step map with a dot whose tooltip says the same or "redone 2026-10-05". +The next reports read `recheck/state.json`: both show one "Database re-check" line in the run facts. A check older than the sample's `run_manifest.tsv` describes an earlier run, so the reports leave it out until the next check. When the sample has a previous run in `history/` ([the delta report](delta-report.md)), a section whose results changed since it carries a badge such as "New since 2026-10-01T09:15:00Z", in both reports, and the delta report heads its changes the same way. The live site's re-check plugin (`bin/owngenome_live_recheck.py`, `assets/live/plugins/recheck.js`) shows the same line as a banner, puts a badge (↻) such as "newer ClinVar available" in the header of every result card of a named step on the person page, and marks each named step on the run timeline with a dot at the end of its line, whose tooltip says the same or "redone 2026-10-05". The Nextflow `HTML_REPORT` process sees no `history/` folder, so its report has no "New since" badge. diff --git a/scripts/ci/e2e-case-paths.tsv b/scripts/ci/e2e-case-paths.tsv index a1c3636b..d7c4f0b7 100644 --- a/scripts/ci/e2e-case-paths.tsv +++ b/scripts/ci/e2e-case-paths.tsv @@ -100,7 +100,7 @@ hba-short-read-accuracy-1-nf-report assets/acmg_sf.tsv assets/callability_genes. hla-second-caller-1-bash assets/pgx_genes.tsv assets/stub/db/hla.dat assets/stub/db/kir.dat bin/cn_loci.tsv bin/copy_number.py bin/cyp2d6_depth_check.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/pgx_outside_calls.py bin/render_report.py scripts/08-hla-typing.sh scripts/24-html-report.sh scripts/32-pypgx.sh scripts/36-pgx-consensus.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh hla-second-caller-2-nextflow assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/mito_confirmed.tsv assets/pgx_genes.tsv assets/stub/db/hla.dat assets/stub/db/kir.dat assets/stub/sample.vcf.gz bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/cyp2d6_depth_check.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/pgx_mito_merge.py bin/pgx_outside_calls.py bin/pgx_refblock_fill.py bin/render_report.py bin/results_index.py modules/local/hla_typing/* modules/local/html_report/* modules/local/pgx_consensus/* modules/local/pharmcat/* modules/local/t1k_build/* modules/local/vcf_precheck/* scripts/07-pharmacogenomics.sh scripts/08-hla-typing.sh scripts/21-cyrius.sh scripts/24-html-report.sh scripts/32-pypgx.sh scripts/36-pgx-consensus.sh scripts/ci/fake-docker/lib.sh scripts/cyrius-constraints.txt scripts/setup.sh scripts/validate-setup.sh ibd-segments-1 assets/families.example.tsv assets/par_grch38.bed assets/pedigree.example.tsv assets/stub/pedigree.tsv bin/family_report.py bin/family_segments.py bin/ibd_report.py scripts/38-family.sh scripts/ci/fake-docker/lib.sh scripts/setup.sh -live-server-1-weblog assets/live/home.html assets/live/index.html assets/live/live.css assets/live/login.html assets/viewer/index.html bin/live_results.py bin/owngenome_live.py bin/owngenome_live_results.py bin/render_report.py bin/run_state.py modules/local/roh/* modules/local/vcf_precheck/* scripts/11-roh-analysis.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh +live-server-1-weblog assets/live/finding.html assets/live/home.html assets/live/index.html assets/live/live.css assets/live/login.html assets/viewer/index.html bin/live_results.py bin/owngenome_live.py bin/owngenome_live_results.py bin/render_report.py bin/run_state.py modules/local/roh/* modules/local/vcf_precheck/* scripts/11-roh-analysis.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh lpa-1 assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/mito_confirmed.tsv assets/par_grch38.bed assets/stub/sample.vcf.gz bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/render_report.py bin/results_index.py modules/local/deepvariant/* modules/local/html_report/* modules/local/lpa/* modules/local/vcf_precheck/* scripts/24-html-report.sh scripts/46-lpa.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh mei-1 assets/mei_sites.tsv assets/par_grch38.bed assets/sv_rule_regions.tsv bin/annotsv_postfilter.py bin/cnvpytor_to_vcf.py bin/collect_summary.py bin/mei_report.py bin/sv_svlen_fix.py modules/local/align_minimap2/* modules/local/annotsv/* modules/local/deepvariant/* modules/local/fastp/* modules/local/mei/* modules/local/vcf_precheck/* scripts/01b-fastp-qc.sh scripts/05-annotsv.sh scripts/15-duphold.sh scripts/18-cnvpytor.sh scripts/19-delly.sh scripts/50-mei.sh scripts/ci/check-images.sh scripts/ci/fake-docker/lib.sh scripts/setup.sh scripts/validate-setup.sh tests/fixtures/annotsv/class45.sh mito-extended-1-deletions assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/construct_variants.tsv assets/deep_intronic_sites.tsv assets/mito_confirmed.tsv assets/stub/db/clinvar.vcf.gz assets/stub/db/clinvar.vcf.gz.tbi assets/stub/sample.bam assets/stub/sample.bam.bai assets/stub/sample.vcf.gz bin/cdna_scan.py bin/clinvar_hits.awk bin/clinvar_join_adjacent.awk bin/clip_check.py bin/collect_summary.py bin/render_report.py bin/results_index.py modules/local/clinvar_screen/* modules/local/html_report/* modules/local/mito_variants/* modules/local/vcf_precheck/* scripts/06-clinvar-screen.sh scripts/16b-mosdepth.sh scripts/20-mtoolbox.sh scripts/24-html-report.sh scripts/ci/build-mito-confirmed.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh From bddbebd47c06603f8fdf8381743450892909948c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:38:42 +0100 Subject: [PATCH 14/20] test(live): wait for a widget's card by a string, not the element, so each page loads in a second The wait returned the DOM node, which never serialises as true, so every widget page waited its full 20 s timeout and the unit-test job ran past its 10 minutes. Bead: pgp-5bja.31 --- tests/test_live_demo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 67ff83e8..e252bb6e 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -331,7 +331,8 @@ def widget_counts(b, base): b.go("about:blank") # a new document each time, so the wait never reads the page before b.wait("location.href === 'about:blank'") b.go(f"{base}/runs/{person}/#s/{cid}") - b.wait("document.documentElement.dataset.pgpResults && document.getElementById(%s)" % json.dumps("card-" + cid)) + b.wait("document.documentElement.dataset.pgpResults && !!document.getElementById(%s) && 'drawn'" + % json.dumps("card-" + cid)) if kind in ("MoChA bar", "AnnotSV span"): b.wait("document.documentElement.dataset.pgpCharts") time.sleep(0.5) From 377970b6588e6987338950414c710d08e5ff6f39 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:57:46 +0100 Subject: [PATCH 15/20] fix(live): keep a child's result closed everywhere until opened, and tighten the Journal levels A person in a family whose roles are not known yet is closed too, and the names of a child's findings to act on stay out of the lead, the ledger, the home page and the finding page. Two copies of a carrier gene and a full-mutation repeat go to a doctor, each ClinVar allele has its own finding, and the lead claims the identity checks only when they ran. The home page follows a running run and redraws while it goes; a finding page without a session leads to the login. Bead: pgp-5bja.31 --- assets/live/core.js | 4 +- assets/live/family-page.js | 9 ++-- assets/live/finding.js | 43 +++++++++++++------ assets/live/home.js | 78 +++++++++++++++++++++++++---------- assets/live/live.css | 1 + assets/live/live.js | 10 +++-- assets/live/person.js | 51 +++++++++++++++++------ bin/live_results.py | 69 ++++++++++++++++++++++--------- bin/owngenome_live.py | 19 ++++++++- bin/owngenome_live_results.py | 9 +++- docs/live-site.md | 8 ++-- tests/test_live_journal.py | 46 ++++++++++++++------- 12 files changed, 252 insertions(+), 95 deletions(-) diff --git a/assets/live/core.js b/assets/live/core.js index a1ea3033..b6e4ff07 100644 --- a/assets/live/core.js +++ b/assets/live/core.js @@ -135,6 +135,8 @@ add(s, el('span', { 'class': 'g', 'aria-hidden': 'true' }), words === undefined ? LEVEL_WORDS[lv] || lv : words); return s; } + // A finding's level: a data-quality finding is always a problem with the data, so its mark is red. + function findingLevel(lv, words) { return level(lv, words, lv === 'trust' ? 'problem' : undefined); } // GRCh38: [name, length Mb, centromere Mb]. var CHR = [['1', 248.96, 123.4], ['2', 242.19, 93.9], ['3', 198.3, 90.9], ['4', 190.21, 50], ['5', 181.54, 48.8], ['6', 170.81, 59.8], ['7', 159.35, 60.1], ['8', 145.14, 45.2], ['9', 138.39, 43], ['10', 133.8, 39.8], @@ -207,7 +209,7 @@ } window.PGPUI = { el: el, svg: svg, add: add, getJSON: getJSON, avatar: avatar, day: day, caveat: caveat, - facts: facts, table: table, tablist: tablist, firstSentence: firstSentence, level: level, glyph: glyph, + facts: facts, table: table, tablist: tablist, firstSentence: firstSentence, level: level, findingLevel: findingLevel, glyph: glyph, karyogram: karyogram, locus: locus, LEVEL_WORDS: LEVEL_WORDS, LEVEL_SHORT: LEVEL_SHORT, LEVEL_ORDER: LEVEL_ORDER, hm: hm, dm: dm, hours: hours }; }()); diff --git a/assets/live/family-page.js b/assets/live/family-page.js index ee893160..3a0213ff 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -31,12 +31,15 @@ return i >= 0 ? PCOL[i % PCOL.length] : null; } // The four equally likely children of two carriers of one recessive gene: 1 affected, 2 carriers, 1 neither. + // Drawn only when the screen's own chance is 1 in 4: another chance (a partner with two copies) has + // another split, and the row's words say it. function fourChildren(risk) { + if (!/^\s*1 in 4\b/.test(risk || '')) { return null; } var box = el('div', { 'class': 'kids4' }); var g = el('div', { 'class': 'grid4', role: 'img', 'aria-label': 'Each pregnancy: 1 in 4 affected, 2 in 4 carriers, 1 in 4 neither' }); add(g, el('span', { 'class': 'aff', title: 'affected' }), el('span', { title: 'carrier' }), el('span', { title: 'carrier' }), el('span', { 'class': 'non', title: 'neither' })); - add(box, g, add(el('div', { 'class': 'kt' }), el('b', {}, risk || '1 in 4'), ' per pregnancy affected', el('br', {}), + add(box, g, add(el('div', { 'class': 'kt' }), el('b', {}, '1 in 4'), ' per pregnancy affected', el('br', {}), '2 in 4 carriers, not affected; 1 in 4 neither')); return box; } @@ -47,7 +50,7 @@ out.push([seq + ' genome' + (seq === 1 ? '' : 's') + ' of this family are read together here. ', false]); (st.couples || []).forEach(function (c) { if (!c.screened) { out.push([c.a + ' and ' + c.b + ' are not screened as a couple yet. ', false]); return; } - if (c.shared) { out.push([c.a + ' and ' + c.b + ' both carry a change in ' + c.shared + ' gene' + (c.shared > 1 ? 's' : '') + ': 1 in 4 per pregnancy for each. ', true]); } + if (c.shared) { out.push([c.a + ' and ' + c.b + ' both carry a change in ' + c.shared + ' gene' + (c.shared > 1 ? 's' : '') + ': chapter III gives the chance per pregnancy for each. ', true]); } else { out.push([c.a + ' and ' + c.b + ' share no carrier gene in what the screen sees; ', false], ['it does not see everything. ', false]); } if (c.x_linked) { out.push([c.x_linked + ' X-linked finding' + (c.x_linked > 1 ? 's' : '') + ' bear on sons. ', true]); } }); @@ -180,7 +183,7 @@ var box = el('div', { 'class': 'mini', 'data-shared': r.gene }); add(box, el('h3', { 'class': 'h3' }, (r.condition || r.gene) + ' (' + r.gene + ')')); add(box, el('p', { 'class': 'soft' }, 'Both carry a change in ' + r.gene + '. Carriers are not affected; a child who inherits both changes is.')); - add(box, fourChildren(r.offspring_risk)); + add(box, fourChildren(r.offspring_risk) || el('p', {}, 'Chance per pregnancy, as the screen gives it: ' + (r.offspring_risk || 'not given') + '.')); add(s, box); }); add(s, U.table({ columns: ['Result', 'Gene', 'Condition', 'Chance for a child', c.a, c.b, 'Why'], diff --git a/assets/live/finding.js b/assets/live/finding.js index c2c50d34..aa7b630d 100644 --- a/assets/live/finding.js +++ b/assets/live/finding.js @@ -72,14 +72,20 @@ root.setAttribute('data-pgp-status', 'ready'); return; } - document.title = f.title + ': ' + r.sample + ': OwnGenome'; - document.getElementById('finding-crumb').textContent = f.title; + var unknown = family === UNKNOWN; + if (unknown) { family = null; } var me = family && (family.state.members || []).filter(function (m) { return m.sample === r.sample; })[0]; - var child = !!(me && (me.father || me.mother)); + var child = unknown || !!(me && (me.father || me.mother)); + // Closed: no title, gene or variant anywhere on the page (its title, crumb, cover, rail) until opened. + var closed = child && f.level === 'act' && !draw.open; + var title = closed ? 'A result to act on' : f.title; + document.title = title + ': ' + r.sample + ': OwnGenome'; + document.getElementById('finding-crumb').textContent = title; var sec = (r.journal || []).filter(function (s) { return s.n === f.section; })[0]; var same = (r.findings || []).filter(function (x) { return x.level === f.level; }); var order = (r.findings || []).filter(function (x) { return x.level !== 'info'; }); var next = order[order.indexOf(f) + 1]; + if (next && child && next.level === 'act' && !draw.open) { next = null; } // The rail: back to the report, this page's parts, the next finding of the report. var rail = el('aside', { 'class': 'toc frail', 'aria-label': 'This finding' }); var back = el('a', { 'class': 'back', href: BASE + '#f/' + f.id }); @@ -104,18 +110,18 @@ } var cover = el('div', { 'class': 'fcover', id: 'what' }); var lv = el('div', { 'class': 'fkick' }); - add(lv, U.level(f.level, f.level_words)); + add(lv, U.findingLevel(f.level, f.level_words)); add(lv, el('span', { 'class': 'dim' }, same.length === 1 ? 'The only finding at this level in ' + r.sample + '’s genome' : same.length + ' findings at this level in ' + r.sample + '’s genome')); add(cover, lv); - add(cover, el('h1', { 'class': 'h-display' }, f.title)); + add(cover, el('h1', { 'class': 'h-display' }, title)); var chips = el('div', { 'class': 'chips' }); var cs = []; if (f.gene) { cs.push(f.gene); } if (f.detail) { cs.push(f.detail); } if (f.viewer && cs.join(' ').indexOf(f.viewer) < 0) { cs.push(f.viewer); } cs.forEach(function (c) { add(chips, el('span', { 'class': 'chip mono' }, c)); }); - if (cs.length) { add(cover, chips); } + if (cs.length && !closed) { add(cover, chips); } var main0 = el('div', {}); add(main0, cover); var acts = el('aside', { 'class': 'actions' }); @@ -126,7 +132,7 @@ var top = el('div', { 'class': 'row' }); add(top, main0, acts); add(col, top); - if (child && f.level === 'act' && !draw.open) { + if (closed) { var v = el('div', { 'class': 'veil' }); add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), 'A result to act on in adult life is usually not looked at before the child can choose. It stays closed until you open it.')); @@ -179,7 +185,8 @@ add(col, sr); } // Related findings and the family. - var rel = (f.related || []).map(function (id) { return (r.findings || []).filter(function (x) { return x.id === id; })[0]; }).filter(Boolean); + var rel = (f.related || []).map(function (id) { return (r.findings || []).filter(function (x) { return x.id === id; })[0]; }) + .filter(function (x) { return x && !(child && x.level === 'act' && !draw.open); }); if (family) { var sf = el('section', { 'class': 'sec', id: 'family' }); add(sf, add(el('div', { 'class': 'sechead' }), el('span', { 'class': 'secnum' }, ROMAN[parts.length - 3]), el('h2', { 'class': 'h2' }, 'In the family'))); @@ -200,7 +207,7 @@ var ul = el('ul', { 'class': 'list-findings' }); rel.forEach(function (x) { var li = el('li', {}); - add(li, el('a', { 'class': 't', href: '../' + encodeURIComponent(x.id) + '/' }, x.title), U.level(x.level, x.level_words)); + add(li, el('a', { 'class': 't', href: '../' + encodeURIComponent(x.id) + '/' }, x.title), U.findingLevel(x.level, x.level_words)); add(li, el('span', { 'class': 'x' }, x.text)); add(ul, li); }); @@ -219,19 +226,29 @@ root.setAttribute('data-pgp-status', 'ready'); } var familyUrl = null; + var UNKNOWN = { unknown: true }; function familyOf(st) { var fam = st && st.plugins && st.plugins.family; var f = fam && fam.kind === 'member' && (fam.families || [])[0]; if (!f) { return Promise.resolve(null); } familyUrl = f.url; - return U.getJSON(abs(f.url) + 'family.json').then(function (d) { return d && d.state ? d : null; }).catch(function () { return null; }); + // A family that does not answer: roles unknown, so a result to act on stays closed (draw()). + return U.getJSON(abs(f.url) + 'family.json').then(function (d) { return d && d.state ? d : UNKNOWN; }).catch(function () { return UNKNOWN; }); + } + function failed() { + main.textContent = ''; + add(main, el('p', { 'class': 'empty' }, 'The results of this run could not be read just now.')); + var b = el('button', { type: 'button', 'class': 'btn' }, 'Try again'); + b.addEventListener('click', function () { main.textContent = ''; add(main, el('p', { 'class': 'dim' }, 'Loading the finding.')); start(); }); + add(main, b); + root.setAttribute('data-pgp-status', 'offline'); } function start() { var st = replay ? U.getJSON(BASE + 'frames.json').then(function (d) { var fr = (d && d.frames) || []; return fr.length ? fr[fr.length - 1].state : null; }) : U.getJSON(BASE + 'state.json'); - Promise.all([U.getJSON(BASE + 'results.json'), st.then(familyOf).catch(function () { return null; })]).then(function (x) { - if (x[0]) { draw(x[0], x[1]); } - }).catch(function () { root.setAttribute('data-pgp-status', 'offline'); }); + Promise.all([U.getJSON(BASE + 'results.json'), st.then(familyOf).catch(function () { return UNKNOWN; })]).then(function (x) { + if (x[0]) { draw(x[0], x[1]); } else { failed(); } + }).catch(failed); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } }()); diff --git a/assets/live/home.js b/assets/live/home.js index 4dddee7e..87c9adfb 100644 --- a/assets/live/home.js +++ b/assets/live/home.js @@ -54,9 +54,11 @@ add(meta, (fam ? role(m, fam) + ' · ' : '') + progressText(r)); if (r && r.failed) { add(meta, ' · ', el('span', { 'class': 'warn' }, r.failed + ' step' + (r.failed > 1 ? 's' : '') + ' not assessed')); } add(div, meta); - if (r && r.lead) { + var child = !!fam && fam.members.some(function (x) { return x.sample === m.father || x.sample === m.mother; }); + var lead = r && (child ? r.lead_closed || '' : r.lead); + if (lead) { // Two sentences: the first often says only that nothing needs a doctor, the second names what differs. - var one = U.firstSentence(r.lead), rest = r.lead.slice(one.length).trim(); + var one = U.firstSentence(lead), rest = lead.slice(one.length).trim(); add(div, el('p', { 'class': 'lead' }, rest ? one + ' ' + U.firstSentence(rest) : one)); } else if (!r) { add(div, el('p', { 'class': 'lead' }, 'In the family tree, with no genome on this server.')); } @@ -64,31 +66,48 @@ if (r) { add(div, el('a', { 'class': 'read', href: r.url }, 'Read the report →')); } return div; } - // A small pedigree: parents on top, children under them; filled when sequenced, dashed when not. + // A small pedigree, one row per generation; filled when sequenced, dashed when not. Square male, + // circle female, diamond when the sex is not known. function miniPedigree(fam, colourOf) { - var ms = fam.members, W = 260, H = 130; - var kids = ms.filter(function (m) { return ms.some(function (x) { return x.sample === m.father || x.sample === m.mother; }); }); - var top = ms.filter(function (m) { return kids.indexOf(m) < 0; }); + var ms = fam.members, W = 260, GAP = 66; + var by = {}; + ms.forEach(function (m) { by[m.sample] = m; }); + var depth = {}; + function gen(m, seen) { + if (depth[m.sample] !== undefined) { return depth[m.sample]; } + if (seen[m.sample]) { return 0; } // a loop in a malformed file: stop rather than recurse + seen[m.sample] = true; + var ps = [m.father, m.mother].filter(function (p) { return by[p]; }); + depth[m.sample] = ps.length ? 1 + Math.max.apply(null, ps.map(function (p) { return gen(by[p], seen); })) : 0; + return depth[m.sample]; + } + ms.forEach(function (m) { gen(m, {}); }); + var rows = []; + ms.forEach(function (m) { (rows[depth[m.sample]] = rows[depth[m.sample]] || []).push(m); }); + var H = 34 + (rows.length - 1) * GAP + 32; var s = U.svg('svg', { viewBox: '0 0 ' + W + ' ' + H, width: W, height: H, role: 'img', 'aria-label': 'Pedigree of ' + fam.name }); var pos = {}; - function row(list, y) { list.forEach(function (m, i) { pos[m.sample] = { x: (i + 1) * W / (list.length + 1), y: y }; }); } - row(top, 34); - row(kids, 100); - kids.forEach(function (k) { + rows.forEach(function (list, g) { + (list || []).forEach(function (m, i) { pos[m.sample] = { x: (i + 1) * W / (list.length + 1), y: 34 + g * GAP }; }); + }); + ms.forEach(function (k) { var ps = [k.father, k.mother].filter(function (p) { return pos[p]; }); if (!ps.length) { return; } + var py = Math.max.apply(null, ps.map(function (p) { return pos[p].y; })); var mx = ps.reduce(function (a, p) { return a + pos[p].x; }, 0) / ps.length; - if (ps.length === 2) { s.appendChild(U.svg('line', { x1: pos[ps[0]].x, x2: pos[ps[1]].x, y1: 34, y2: 34, stroke: '#8f8d86' })); } - s.appendChild(U.svg('line', { x1: mx, x2: mx, y1: 34, y2: 66, stroke: '#8f8d86' })); - s.appendChild(U.svg('line', { x1: mx, x2: pos[k.sample].x, y1: 66, y2: 66, stroke: '#8f8d86' })); - s.appendChild(U.svg('line', { x1: pos[k.sample].x, x2: pos[k.sample].x, y1: 66, y2: 88, stroke: '#8f8d86' })); + var mid = (py + pos[k.sample].y) / 2; + if (ps.length === 2) { s.appendChild(U.svg('line', { x1: pos[ps[0]].x, x2: pos[ps[1]].x, y1: py, y2: py, stroke: '#8f8d86' })); } + s.appendChild(U.svg('line', { x1: mx, x2: mx, y1: py, y2: mid, stroke: '#8f8d86' })); + s.appendChild(U.svg('line', { x1: mx, x2: pos[k.sample].x, y1: mid, y2: mid, stroke: '#8f8d86' })); + s.appendChild(U.svg('line', { x1: pos[k.sample].x, x2: pos[k.sample].x, y1: mid, y2: pos[k.sample].y - 11, stroke: '#8f8d86' })); }); ms.forEach(function (m) { var p = pos[m.sample], c = colourOf[m.sample] || '#8f8d86'; var attrs = { fill: '#15161a', stroke: m.sequenced ? c : '#8f8d86', 'stroke-width': 2 }; if (!m.sequenced) { attrs['stroke-dasharray'] = '3 3'; } var shape = m.sex === 'male' ? U.svg('rect', Object.assign({ x: p.x - 11, y: p.y - 11, width: 22, height: 22 }, attrs)) - : U.svg('circle', Object.assign({ cx: p.x, cy: p.y, r: 11 }, attrs)); + : m.sex === 'female' ? U.svg('circle', Object.assign({ cx: p.x, cy: p.y, r: 11 }, attrs)) + : U.svg('polygon', Object.assign({ points: [p.x, p.y - 13, p.x + 13, p.y, p.x, p.y + 13, p.x - 13, p.y].join(' ') }, attrs)); s.appendChild(shape); var t = U.svg('text', { x: p.x, y: p.y + 26, 'text-anchor': 'middle', fill: '#c3c2b7', 'font-size': 11 }); t.textContent = m.sample; @@ -110,6 +129,13 @@ var going = runs.filter(function (r) { return r.state === 'running'; }); var done = runs.filter(function (r) { return r.state === 'finished'; }); var acts = runs.reduce(function (a, r) { return a + ((r.levels && r.levels.act) || 0); }, 0); + var assessed = runs.filter(function (r) { return r.levels; }).length; + // A child of a family: results to act on stay closed on this page too (the report opens them). + var children = {}; + fams.forEach(function (f) { + f.members.forEach(function (m) { if (f.members.some(function (x) { return x.sample === m.father || x.sample === m.mother; })) { children[m.sample] = true; } }); + }); + function closedTitle(r, f) { return children[r.name] && f.level === 'act' ? 'A result to act on, closed until opened on the report' : f.title; } // The dateline and the headline sentence. var dl = el('div', { 'class': 'dateline' }); var today = new Date(); @@ -125,7 +151,10 @@ else { if (going.length) { add(h1, cap(word(going.length)) + ' report' + (going.length > 1 ? 's are ' : ' is '), el('em', {}, 'being written'), '. '); } add(h1, (going.length ? cap(word(done.length)) + (done.length === 1 ? ' is' : ' are') : cap(word(done.length)) + ' report' + (done.length === 1 ? ' is' : 's are')) + ' finished, and ' + - (acts ? word(acts) + ' finding' + (acts === 1 ? ' should' : 's should') + ' go to a doctor.' : 'no finding in what was assessed needs a doctor.')); + (acts ? word(acts) + ' finding' + (acts === 1 ? ' should' : 's should') + ' go to a doctor.' + : !assessed ? 'none has results to read yet.' + : assessed < runs.length ? 'no finding in the ' + word(assessed) + ' with results so far needs a doctor.' + : 'no finding in what was assessed needs a doctor.')); } add(left, h1); var sub = fams.map(function (f) { return 'Family ' + f.name + ': ' + f.members.filter(function (m) { return m.sequenced; }).length + ' genomes, read alone and together.'; }); @@ -142,8 +171,9 @@ var seen = {}; (r.top || []).forEach(function (f) { if (order[f.level] === undefined) { return; } - if (seen[f.level]) { seen[f.level].more += 1; return; } - seen[f.level] = { r: r, f: f, more: 0 }; + if (seen[f.level]) { return; } + // The rest of the level from its count, not from the first four findings the index names. + seen[f.level] = { r: r, f: f, more: Math.max(0, ((r.levels && r.levels[f.level]) || 1) - 1) }; items.push(seen[f.level]); }); }); @@ -154,7 +184,7 @@ var pc = el('span', { 'class': 'pc' }, x.r.name); if (colourOf[x.r.name]) { pc.style.setProperty('--pcol', colourOf[x.r.name]); } add(who, pc, ' · ' + U.LEVEL_WORDS[x.f.level].toLowerCase()); - add(a, U.level(x.f.level, ''), who, el('span', { 'class': 'what' }, x.f.title + (x.more ? ' and ' + x.more + ' more' : '')), el('span', { 'class': 'arr', 'aria-hidden': 'true' }, '→')); + add(a, U.findingLevel(x.f.level, ''), who, el('span', { 'class': 'what' }, closedTitle(x.r, x.f) + (x.more ? ' and ' + x.more + ' more' : '')), el('span', { 'class': 'arr', 'aria-hidden': 'true' }, '→')); add(ul, add(el('li', {}), a)); }); if (!items.length) { add(ul, add(el('li', {}), el('p', { 'class': 'soft' }, runs.length ? 'No finding to act on, for a prescriber or for family planning in what the finished steps assessed. Each report says what its steps do not see.' : 'Nothing yet.'))); } @@ -186,7 +216,7 @@ var c2 = el('div', {}); add(c2, el('div', { 'class': 'fig-label' }, 'Found so far')); var fs = el('ul', { 'class': 'fs' }); - (r.top || []).forEach(function (f) { add(fs, add(el('li', {}), U.level(f.level, ''), ' ', f.title)); }); + (r.top || []).forEach(function (f) { add(fs, add(el('li', {}), U.findingLevel(f.level, ''), ' ', closedTitle(r, f))); }); if (!fs.childNodes.length) { add(fs, el('li', {}, r.lead ? U.firstSentence(r.lead) : 'No section is written yet: the first ones appear as their steps end.')); } add(c2, fs); var c3 = el('div', {}); @@ -256,9 +286,13 @@ root.setAttribute('data-pgp-status', 'ready'); } + // While a run goes, the page asks again every 30 seconds, as the family page does. function start() { - U.getJSON(root.getAttribute('data-data') || '/runs.json').then(function (d) { if (d) { draw(d); } }) - .catch(function () { root.setAttribute('data-pgp-status', 'offline'); }); + U.getJSON(root.getAttribute('data-data') || '/runs.json').then(function (d) { + if (!d) { return; } + draw(d); + if ((d.runs || []).some(function (r) { return r.state === 'running'; })) { setTimeout(start, 30000); } + }).catch(function () { root.setAttribute('data-pgp-status', 'offline'); setTimeout(start, 30000); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } }()); diff --git a/assets/live/live.css b/assets/live/live.css index 88151b46..e04b4d6b 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -425,6 +425,7 @@ table.tasks th:first-child, table.tasks td:first-child { text-align: left; } .fkick { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; font-size: 13px; margin-bottom: 18px; } .fcover .h-display { font-size: 56px; } .chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; } +.fcover .chip { max-width: 100%; white-space: normal; overflow-wrap: anywhere; line-height: 1.35; } .fpage .lede { margin-top: 10px; } .planbox { border: 1px solid var(--hair-2); border-left: 3px solid var(--brass); border-radius: 8px; background: var(--paper); padding: 22px 26px 12px; margin-top: 30px; } .planbox.lvl-act { border-left-color: var(--act); } diff --git a/assets/live/live.js b/assets/live/live.js index d1af44ba..a019eaaa 100644 --- a/assets/live/live.js +++ b/assets/live/live.js @@ -52,8 +52,9 @@ // order assets/steps.tsv plans them. Each line is a button (g.box) that opens the step's detail. function ms(t) { var v = t ? Date.parse(t) : NaN; return isFinite(v) ? v : null; } function clock(v) { var d = new Date(v); return ('0' + d.getUTCHours()).slice(-2) + ':' + ('0' + d.getUTCMinutes()).slice(-2); } - function timeline(steps, nowText) { - var now = ms(nowText); + function timeline(steps, nowText, running) { + // A finished run ends at its last step, not at the moment the page was asked for. + var now = running ? ms(nowText) : null; var known = steps.map(function (s) { return ms(s.started_utc); }).filter(function (v) { return v !== null; }); var t0 = known.length ? Math.min.apply(null, known) : null; var ends = steps.map(function (s) { return ms(s.ended_utc) || (s.state === 'running' ? now : null); }) @@ -76,7 +77,7 @@ function drawMap(steps) { var map = document.getElementById('map'); - var T = timeline(steps, last && (last.generated_utc || null)); + var T = timeline(steps, last && (last.generated_utc || null), !!(last && last.run && last.run.state === 'running')); var h = TOP + T.order.length * ROW_H + AXIS_H; // The chart takes the width the page gives it (at least 300 px of time), so no step sits off screen. var CHART_W = Math.max(300, (map.clientWidth || 962) - LABEL_W - 12); @@ -193,7 +194,8 @@ box.appendChild(el('a', { href: 'report.html#' + encodeURIComponent(anchor) }, 'Open in the report: ' + k)); } }); - drawMap(shown(last.steps)); + // Repaint, never redraw: the focused step keeps its focus and the plugins' marks stay on. + shown(last.steps).forEach(function (s) { paint(s.step, s.state, s.label); }); } function found(state) { diff --git a/assets/live/person.js b/assets/live/person.js index 932ddd76..fc099c61 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -17,6 +17,8 @@ var root = document.documentElement; var replay = root.getAttribute('data-mode') === 'replay'; var results = null, state = null, family = null, familyFor = null, fetchedFor = null, drawnKey = null; + // 'none': in no family; 'pending' or 'failed': in one whose roles are not known (yet); 'known'. + var familyStatus = 'none'; var shown = {}; // veiled findings the reader opened, by id var panel = document.getElementById('results'); var rail = document.getElementById('rail'); @@ -75,12 +77,19 @@ var key = f ? f.url : null; if (key === familyFor) { return; } familyFor = key; - if (!f) { family = null; return; } + if (!f) { family = null; familyStatus = 'none'; return; } + familyStatus = 'pending'; U.getJSON(f.url + 'family.json').then(function (d) { if (familyFor !== key) { return; } family = d && d.state ? { name: f.name, url: f.url, data: d } : null; + familyStatus = family ? 'known' : 'failed'; redraw(true); - }).catch(function () { family = null; }); + }).catch(function () { + if (familyFor !== key) { return; } // a late answer for a family this page no longer shows + family = null; + familyStatus = 'failed'; + redraw(true); + }); } function me() { var ms = (family && family.data.state && family.data.state.members) || []; @@ -89,6 +98,9 @@ // A child of the family: a member whose father or mother is listed. Results to act on about a child // stay closed until someone opens them on purpose. function isChild() { var m = me(); return !!(m && (m.father || m.mother)); } + // Results to act on stay closed for a child, and for anyone in a family whose roles are not known yet: + // the page cannot tell a child apart until family.json has answered. + function closedPage() { return isChild() || familyStatus === 'pending' || familyStatus === 'failed'; } function memberUrl(s) { var r = family && family.data.runs && family.data.runs[s]; return r && r.url ? r.url : null; @@ -393,13 +405,13 @@ add(box, d); return note('Caveat', box, 'caveat-note'); } - function veiled(f) { return isChild() && f.level === 'act' && !shown[f.id]; } + function veiled(f) { return closedPage() && f.level === 'act' && !shown[f.id]; } function findingArticle(f, big) { var row = el('div', { 'class': 'row' }); var art = el('div', { 'class': 'finding', id: 'f-' + f.id, 'data-finding': f.id, 'data-level': f.level }); var at = newAt(f); if (at) { art.classList.add('new'); add(art, el('span', { 'class': 'newband' }, 'New at ' + U.hm(at))); add(art, el('br', {})); } - add(art, U.level(f.level, f.level_words)); + add(art, U.findingLevel(f.level, f.level_words)); if (veiled(f)) { var v = el('div', { 'class': 'veil' }); add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), @@ -427,7 +439,7 @@ var ev = evidence(f.evidence); if (ev) { add(side, note('Evidence', ev)); } add(side, caveatNote(f.caveat)); - var rel = (f.related || []).map(findingById).filter(Boolean); + var rel = (f.related || []).map(findingById).filter(function (r) { return r && !veiled(r); }); if (rel.length) { var box = el('div', {}); rel.slice(0, 4).forEach(function (r, i) { if (i) { add(box, el('br', {})); } add(box, el('a', { href: '#f/' + r.id }, r.title)); }); @@ -438,7 +450,7 @@ } function findingLine(f) { var li = el('li', { id: 'f-' + f.id, 'data-finding': f.id, 'data-level': f.level }); - add(li, el('a', { 'class': 't', href: findingHref(f) }, f.title), U.level(f.level, f.level_words)); + add(li, el('a', { 'class': 't', href: findingHref(f) }, f.title), U.findingLevel(f.level, f.level_words)); if (f.detail) { add(li, el('span', { 'class': 'gene' }, f.detail)); } add(li, el('span', { 'class': 'x' }, f.text)); return li; @@ -446,8 +458,13 @@ // --- the sections --------------------------------------------------------------------------------- // A section's stamp: written (with the time its last step ended), being written (a step runs), waiting. + // The chapters whose steps can still add a finding to sections 1 to 4 while they run. + var FEEDS = { clinical: true, carrier: true, medicines: true }; + function feeding(sec) { + return sec.n <= 4 ? cards().filter(function (c) { return FEEDS[c.chapter] && sec.cards.indexOf(c.id) < 0; }) : []; + } function stamp(sec) { - var cs = sec.cards.map(cardById).filter(Boolean); + var cs = sec.cards.map(cardById).filter(Boolean).concat(feeding(sec)); if (!cs.length) { return { k: 'written', t: 'Written' }; } var gs = cs.map(gate); if (gs.some(function (g) { return g === 'running'; })) { return { k: 'writing', t: 'Being written' }; } @@ -490,9 +507,9 @@ else { var ul = el('ul', { 'class': 'list-findings' }); fs.forEach(function (f) { add(ul, findingLine(f)); }); add(body, ul); } } else if (sec.n !== 5) { var w = waitBlock(sec); - var pend = sec.n === 1 && cards().some(function (c) { return c.section === 8 && pending(gate(c)); }); + var pend = feeding(sec).some(function (c) { return pending(gate(c)); }); if (w) { add(body, w); } else if (pend) { - add(body, el('p', { 'class': 'waitnote' }, 'This section stays open until the clinical steps finish. Until then this page says nothing either way.')); + add(body, el('p', { 'class': 'waitnote' }, 'This section stays open until the steps that can add a finding here finish. Until then this page says nothing either way.')); } else { var e = el('p', { 'class': 'empty-sec' }, NOT_ALL_CLEAR); add(body, e); @@ -582,8 +599,15 @@ var row = el('div', { 'class': 'row' }); var main = el('div', {}); var p = el('p', { 'class': 'lede dropcap', id: 'lead' }); - var parts = (results.lead || []).slice(); - if (running()) { + var closed = (results.findings || []).some(veiled); + var parts = (results.lead || []).map(function (x) { + return x[2] === 'act' && closed ? [': closed on this page until opened on purpose, in section 1. ', false] : x; + }); + if (replay && running()) { + // A replay holds the results of the finished run: before its end the lead would name findings the + // sections do not show yet. + parts = [['This report is being written. ', true], ['The lead paragraph is written when the run ends; the sections below fill in as their steps finish.', false]]; + } else if (running()) { parts = [['This report is being written. ', true], ['What follows is what has been found so far; a section with steps still to run says so. ', false]] .concat(parts.filter(function (x) { return !/^Mean depth/.test(x[0]); })); } @@ -596,7 +620,7 @@ var a = el('a', { href: '#s' + x[1], 'data-level': x[0] }); add(a, U.level(x[0], { act: 'Take to a doctor', medicine: 'Prescriber', review: 'Worth a look', carrier: 'Family planning', info: 'Information' }[x[0]])); add(a, el('span', { 'class': 'n' }, String(n[x[0]]))); - var words = x[0] === 'info' ? 'Origins, traits, scores' : + var words = x[0] === 'info' ? 'Origins, traits, scores' : x[0] === 'act' && closed && n.act ? 'Closed until opened' : fs.filter(function (f) { return f.level === x[0]; }).slice(0, 2).map(function (f) { return f.gene || f.title; }).join(', ') || 'None in what was assessed'; add(a, el('span', { 'class': 'w' }, words)); add(led, a); @@ -853,7 +877,8 @@ function redraw(force) { var k = JSON.stringify([doneSet(), state && state.run && state.run.state, !!results, !!family, Object.keys(shown), state && state.plugins && state.plugins.recheck && state.plugins.recheck.mtime, - ((state && state.steps) || []).filter(function (s) { return s.state === 'running'; }).map(function (s) { return s.step; })]); + // Every step's state: one can move from waiting to not finished with the done set unchanged. + ((state && state.steps) || []).map(function (s) { return s.step + ':' + s.state; })]); if (!force && k === drawnKey) { header(); return; } drawnKey = k; var y = window.scrollY; diff --git a/bin/live_results.py b/bin/live_results.py index 1b0d9d94..19781d14 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -24,6 +24,7 @@ fields the summary already holds. A section id with no adapter falls back to its report headline and a link to the report. """ +import hashlib import re import render_report as rr @@ -732,8 +733,11 @@ def card(sid, title, keys, summary, files): def slug(*parts): """A finding's id: lower case, digits and dashes, at most 80 characters (the route's own pattern).""" - t = re.sub(r"[^a-z0-9]+", "-", "-".join(str(p) for p in parts if p not in (None, "")).lower()).strip("-") - return (t[:80].rstrip("-") or "finding") + raw = "-".join(str(p) for p in parts if p not in (None, "")) + t = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-") + if len(t) > 80: # a long allele: cut, and keep two ids apart by a hash of the whole key + t = t[:71].rstrip("-") + "-" + hashlib.sha1(raw.encode()).hexdigest()[:8] + return t or "finding" FINDING_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$") @@ -790,7 +794,7 @@ def finding_rows(summary): if g in sf_genes or g in carrier_genes or g in two_hit: continue var = f"{h.get('chrom')}:{h.get('pos')} {h.get('ref')}>{h.get('alt')}" - out.append(_f("review", "clinvar", ("clinvar", g, h.get("chrom"), h.get("pos")), + out.append(_f("review", "clinvar", ("clinvar", g, h.get("chrom"), h.get("pos"), h.get("ref"), h.get("alt")), f"{h.get('significance') or 'Pathogenic'} change in {g}", f"ClinVar lists this change in {g} as {str(h.get('significance') or '').lower()}; this genome " f"has {_copies(h.get('genotype'))}. Whether it matters depends on how the gene is inherited: " @@ -816,15 +820,18 @@ def finding_rows(summary): if not st and not l.get("zone"): continue full = any("full" in x for x in st) - what = (STR_WORDS.get(st[0], st[0].replace("_", " ")) if st + lead_st = next((x for x in st if "full" in x), st[0]) if st else None + what = (STR_WORDS.get(lead_st, lead_st.replace("_", " ")) if lead_st else f"{l.get('zone_label') or 'intermediate'} range") + count = rr.str_count(l) if l.get("repeat_count") else "not given" out.append(_f("act" if full else "review", "expansions", ("str", l.get("locus")), f"{l.get('locus')} repeat in the {what}", - f"{l.get('locus')} has {l.get('repeat_count')} repeats: Stranger reads it as \"{what}\". " + f"{l.get('locus')} has {count} repeats: Stranger reads it as \"{what}\". " f"{'See a clinical geneticist.' if full else 'It may grow when passed on; worth a word with a genetic counsellor.'}", - gene=l.get("locus"), detail=f"{l.get('locus')} {l.get('repeat_count')} repeats", - evidence=[["Repeats", l.get("repeat_count")], ["95% interval", l.get("ci")], - ["Pathologic from", l.get("pathologic_min")], ["Sizing", l.get("sizing")]])) + gene=l.get("locus"), detail=f"{l.get('locus')} {count} repeats", + evidence=[["Repeats", count], ["95% interval", l.get("ci")], + ["Pathologic from", l.get("pathologic_min")], + ["Sizing", l.get("allele_sizing") or l.get("sizing")]])) v = _data(S, "vntyper") if v.get("status") in ("positive", "possible"): out.append(_f("act" if v["status"] == "positive" else "review", "vntyper", ("muc1", v["status"]), @@ -856,6 +863,19 @@ def finding_rows(summary): for c in car.get("carriers") or []: if c.get("gene") in two_hit: continue + gt = str(c.get("genotype") or "") + if gt in ("hom", "hemizygous"): + # Both copies changed, or the only copy (X in a male, or inside a deletion): not a carrier. + out.append(_f("act", "carrier-list", ("carrier", c.get("gene"), c.get("variant")), + f"{c.get('condition') or c.get('gene')}: {'two changed copies' if gt == 'hom' else 'the only copy changed'}", + f"This genome has {'two changed copies' if gt == 'hom' else 'no unchanged copy'} of " + f"{c.get('gene')}, a gene of {c.get('condition') or 'a condition on the carrier list'} " + f"({c.get('inheritance') or 'inheritance not given'}). That is not the pattern of a carrier: " + f"a doctor or genetic counsellor reads it.", + gene=c.get("gene"), detail=f"{c.get('gene')} {c.get('variant') or ''} {gt}".strip(), + evidence=[["Genotype", gt], ["Significance", c.get("significance")], ["Stars", c.get("stars")], + ["Inheritance", c.get("inheritance")]])) + continue out.append(_f("carrier", "carrier-list", ("carrier", c.get("gene"), c.get("variant")), f"{c.get('condition') or c.get('gene')} carrier" if c.get("condition") else f"Carrier in {c.get('gene')}", f"{CARRIER_NOTE} This genome has one changed copy of {c.get('gene')}: a carrier, not affected.", @@ -870,11 +890,16 @@ def finding_rows(summary): f"{x.get('condition') or x.get('gene')}: {x.get('risk') or 'see the couple screen'} for each " f"pregnancy, with {cp.get('partner')}.", gene=x.get("gene"), evidence=[["Chance for a child", x.get("risk")], ["Partner", cp.get("partner")]])) - for g in _data(S, "paralogs").get("genes") or []: - if g.get("gene") == "SMN1" and str(g.get("copy_number")) == "1" and g.get("status") == "called": - out.append(_f("carrier", "paralogs", ("smn1",), "Spinal muscular atrophy carrier (SMN1)", - "One copy of SMN1 where most people have two: a carrier of spinal muscular atrophy, not " - "affected.", gene="SMN1", evidence=[["SMN1 copies", "1"]])) + smn1 = [g for g in _data(S, "paralogs").get("genes") or [] if g.get("gene") == "SMN1" and g.get("status") == "called"] + one = smn1 and str(smn1[0].get("copy_number")) == "1" + by = "Paraphase" + if not smn1: # no Paraphase call: Parascopy's resolved count from short reads + cn = ((_data(S, "parascopy").get("smn1") or {}).get("cn")) + one, by = cn == 1, "Parascopy" + if one: + out.append(_f("carrier", "paralogs", ("smn1",), "Spinal muscular atrophy carrier (SMN1)", + "One copy of SMN1 where most people have two: a carrier of spinal muscular atrophy, not " + "affected.", gene="SMN1", evidence=[["SMN1 copies", "1"], ["Called by", by]])) # 8. Medicines: each gene whose called phenotype changes function. for g in _data(S, "cpic").get("genes") or []: if g.get("status") not in ("non-normal", "ambiguous"): @@ -980,8 +1005,8 @@ def _count(n, one, many): def lead(found, levels, summary): - """The report's lead paragraph: [[text, bold]] segments, plain sentences that name the findings by - level. It never says a section is fine: with no finding of a level it says what was assessed.""" + """The report's lead paragraph: [[text, bold]] segments (a third item "act" marks the names of the + findings to act on), plain sentences that name the findings by level. It never says a section is fine: with no finding of a level it says what was assessed.""" S = summary.get("sections") or {} by = {lv: [f for f in found if f["level"] == lv] for lv in LEVELS} out = [] @@ -991,14 +1016,14 @@ def lead(found, levels, summary): n = len(by["act"]) out += [["Of everything this genome was screened for, ", False], [_count(n, "finding should", "findings should") + " go to a doctor", True], - [": " + _names(by["act"]) + ". ", False]] + [": " + _names(by["act"]) + ". ", False, "act"]] # "act": the names a child's page keeps closed elif found or any((S.get(k) or {}).get("state") in ("ok", "stale") for k in ("clinvar", "clinical", "cpsr")): out += [["No finding in what these steps assessed needs a doctor; ", True], ["sections 8 and 9 say what they do not see. ", False]] if by["medicine"]: genes = sorted({f["gene"] for f in by["medicine"] if f["gene"]}) out += [[_count(len(by["medicine"]), "medicine result", "medicine results").capitalize(), True], - [f" bear on how drugs work ({', '.join(genes)}): tell whoever prescribes. ", False]] + [f" bear{'s' if len(by['medicine']) == 1 else ''} on how drugs work ({', '.join(genes)}): tell whoever prescribes. ", False]] if by["carrier"]: out += [[f"A carrier of {_names(by['carrier'], 2)}", True], [": carriers are not affected; it matters when a partner carries the same gene. ", False]] @@ -1006,8 +1031,11 @@ def lead(found, levels, summary): out += [[_count(len(by["review"]), "result is", "results are").capitalize() + " worth a look", True], [": " + _names(by["review"]) + ". ", False]] cov = _data(S, "coverage").get("mean_depth") + q = _data(S, "sample_qc") + checked = q.get("contamination") == "ok" and q.get("sex_check") == "ok" # both ran and raised nothing if not by["trust"] and isinstance(cov, (int, float)): - out.append([f"Mean depth {cov:.1f}×; the identity and contamination checks raised no flag.", False]) + out.append([f"Mean depth {cov:.1f}×" + ("; the identity and contamination checks raised no flag." if checked + else "; the identity and contamination checks have no result here: section 5 says why."), False]) if out: out[-1][0] = out[-1][0].rstrip() return out @@ -1049,10 +1077,13 @@ def overview(summary): act on first (at most four, by level) and the lead as plain text. No file read.""" r = build("", summary) order = {lv: i for i, lv in enumerate(LEVELS)} + order["trust"] = -1 # a problem with the data comes before any finding read from it top = sorted((f for f in r["findings"] if f["level"] != "info"), key=lambda f: order[f["level"]])[:4] return {"attention": r["attention"], "levels": r["levels"], "top": [{"id": f["id"], "level": f["level"], "title": f["title"]} for f in top], - "lead": "".join(t for t, _ in r["lead"])} + "lead": "".join(x[0] for x in r["lead"]), + # The same lead with the names of the findings to act on left out: a child's tile shows this one. + "lead_closed": "".join(x[0] if x[2:] != ["act"] else ": closed until opened on purpose. " for x in r["lead"])} def strings(obj): diff --git a/bin/owngenome_live.py b/bin/owngenome_live.py index e2ac55c3..d0df1e16 100755 --- a/bin/owngenome_live.py +++ b/bin/owngenome_live.py @@ -524,6 +524,7 @@ def __init__(self, app, cfg, password_hash): self.streams = threading.BoundedSemaphore(cfg.max_streams) self.headline_cache = {} self.attention_cache = {} + self.live_overview = {} # run -> (the headline cache key, live_results.overview of that summary) self.allowed = set(LOOPBACK_HOSTS) | {h.lower() for h in cfg.allowed_host or []} if cfg.bind not in ("0.0.0.0", "::"): self.allowed.add(cfg.bind.strip("[]").lower()) @@ -819,6 +820,8 @@ def runs_index(self): # Forget the cached results of runs that are gone, so the caches hold only the runs there are. for k in [k for k in list(self.headline_cache) if k not in runs]: self.headline_cache.pop(k, None) + for k in [k for k in list(self.live_overview) if k not in runs]: + self.live_overview.pop(k, None) keep = {os.path.join(r["dir"] or os.path.join(self.root, n), "summary.json") for n, r in runs.items()} for k in [k for k in list(self.attention_cache) if k not in keep]: self.attention_cache.pop(k, None) @@ -835,10 +838,24 @@ def runs_index(self): failed=sum(1 for s in planned if s["state"] == "failed")) # A run known from its messages alone still has its folder under the root once step 24 wrote there. o, mtime = self.overview(r["dir"] or os.path.join(self.root, name)) + # While a run goes, the summary its page last read (results.json's own) is newer than + # summary.json: count from it, so the home never says less than the person page. + hit = self.headline_cache.get(name) if item["state"] == "running" else None + if hit and hit[2]: + lo = self.live_overview.get(name) + if not lo or lo[0] != hit[0]: + try: + import live_results + lo = (hit[0], live_results.overview(hit[2])) + except Exception as e: # one bad summary must not take the home page down + print(f"owngenome_live: overview of {name} not built: {type(e).__name__}", file=sys.stderr) + lo = (hit[0], None) + self.live_overview[name] = lo + o = lo[1] or o item["attention"] = o["attention"] if o else None item["results"] = mtime is not None if o: # the Journal home: counts by level, the first findings, the lead - item.update(levels=o["levels"], top=o["top"], lead=o["lead"]) + item.update(levels=o["levels"], top=o["top"], lead=o["lead"], lead_closed=o["lead_closed"]) else: item["members"] = r.get("members") or [] out.append(item) diff --git a/bin/owngenome_live_results.py b/bin/owngenome_live_results.py index 3cebff16..e3698175 100644 --- a/bin/owngenome_live_results.py +++ b/bin/owngenome_live_results.py @@ -17,6 +17,7 @@ import html import os import sys +import urllib.parse HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) @@ -57,6 +58,11 @@ def finding(req, m): """One finding's page. The page itself reads ../../results.json; this only checks that the id is a finding of the run now, so a typed or stale address is a plain 404, never an empty page.""" live = req.live + # A page: without a session it sends the browser to the login and back, as the viewer does. + if live.password_hash and req.sid is None: + app.log("finding", m.group(1), "-", 303) + return req.send_body(303, "text/plain", "", [("Location", "/login?next=" + urllib.parse.quote( + urllib.parse.urlsplit(req.path).path))]) run = live.run(m.group(1)) fid = m.group(2) ok = False @@ -77,7 +83,8 @@ def finding(req, m): app.log("finding", run["name"], fid, 200) return req.send_body(200, "text/html; charset=utf-8", page) - app.route("GET", r"/runs/(%s)/finding/([^/]{1,200})/" % NAME, finding) + # auth=False: finding() asks for the login itself, so a bookmarked finding leads to the login form. + app.route("GET", r"/runs/(%s)/finding/([^/]{1,200})/" % NAME, finding, auth=False) def live_results_mod(): diff --git a/docs/live-site.md b/docs/live-site.md index 9c9f3894..b162c08c 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -56,7 +56,7 @@ Every result carries one of six levels, with a word, a shape and a colour, so th 9. **What it cannot see**: what the run does not assess and the steps that did not run. 10. **Sources and files**: the database versions, the database re-check, and the report and its files. -A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose. +A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose: its title, gene and variant stay out of the lead, the ledger, the chromosome map, the home page and its own page too. A person in a family whose family report has not answered yet is treated the same way, since the page cannot tell yet whether they are a child. While the run goes, the page opens on the run instead: a sentence on how far it is ("23 of 27 are done, about 8–13 hours remain"), the steps done as a big number and a bar, the time elapsed, the time to go from the usual step times, the retries, and the tasks running now against their usual time. Each section in the contents says whether it is written, being written or waiting. @@ -72,7 +72,7 @@ The header search finds a gene or rsID across the results and opens the card tha **A family** (`/runs//`). A report in five chapters, opened by a lead: how many genomes are read together, whether the couple shares a carrier gene, whether the measured relationships match the tree, and how many children. I, the pedigree, drawn from `members.tsv` or `pedigree.tsv` in the members' colours, where each sequenced person with a run here opens their page and shows how many cards their report flags, beside the members and the analyses step 38 planned. II, who is related how: step 38's relationship check beside the shared segments, as a grid of every pair, with ⚑ where the measured relationship differs from the declared one. III, each couple's carrier screen in full (genes where both carry a finding, drawn as four children with the chance for each, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report. IV, each child: the stretches inherited from each parent painted per chromosome (coloured by grandparent where step 38 could tell), the de novo candidates and the compound-het pairs, in trans first. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and blood groups, one column each. On a phone the grid becomes a list of pairs. -On a phone each page is one column: the contents give way to a tab bar at the bottom (To act, Medicines, Family, Trust, More), and each table row becomes a short list of label and value. The pages are dark. +On a phone each page is one column and each table row becomes a short list of label and value. On a person's report the contents give way to a tab bar at the bottom (To act, Medicines, Family, Trust, More); a finding page drops its contents (the header links back to the report), and a family page keeps its chapter list above the chapters. The pages are dark. ## The genome viewer @@ -82,9 +82,9 @@ Per run it serves the folder's BAM or CRAM, its VCF and its SV VCF, each with it The reads cross the network to the browser that logged in: only the byte ranges igv.js asks for, but they are the sample's reads. Off loopback, use TLS (`--tls-cert` and `--tls-key`, or a proxy that ends TLS); without it the password and the reads cross the network in clear. Opening the viewer writes one access-log row; the byte ranges igv.js reads write none. -`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. A run with results also has `levels` (its findings counted by level), `top` (up to four findings to show first: id, level, title) and `lead` (its lead paragraph as plain text). +`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. A run with results also has `levels` (its findings counted by level), `top` (up to four findings to show first, data-quality problems first: id, level, title), `lead` (its lead paragraph as plain text) and `lead_closed` (the same with the names of the findings to act on left out, for a child's tile). While a run goes these come from the summary its page last read, so the home page never shows less than the person page. -The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. Each card also has its `level`; the file adds `findings` (each with its id, level, section, title, plain text, gene, evidence, caveat, plan and related findings), `levels` (the counts), `journal` (the ten sections and the cards in each), `lead` (the lead paragraph as text pieces, the bold ones marked), `level_words` and `databases`. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. +The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. Each card also has its `level`; the file adds `findings` (each with its id, level, section, title, plain text, gene, evidence, caveat, plan and related findings), `levels` (the counts), `journal` (the ten sections and the cards in each), `lead` (the lead paragraph as text pieces, the bold ones marked, and the names of the findings to act on marked `act`), `level_words` and `databases`. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. ## Privacy and what is stored diff --git a/tests/test_live_journal.py b/tests/test_live_journal.py index 1b8512ac..8f72cbcf 100644 --- a/tests/test_live_journal.py +++ b/tests/test_live_journal.py @@ -4,18 +4,21 @@ Checks: 1. the level mapping, on a small made-up summary: CPSR's pathogenic row is to act on, a ClinVar hit - outside the carrier list is worth a look, a carrier-list row is for family planning, a CPIC gene + outside the carrier list is worth a look, a carrier-list row is for family planning (two changed + copies of a carrier gene, and a full-mutation repeat, are to act on), a CPIC gene with changed function is for the prescriber, a contamination warning is data quality; each card takes its highest finding's level, the quality chapter is data quality, a failed card has none; every finding id is a safe path part (live_results.FINDING_ID) and sits in its level's section; - 2. the lead paragraph: with a finding to act on it says how many go to a doctor and names each one, - a data-quality problem comes first, and with no finding it says none needs a doctor and points at + 2. the lead paragraph: with a finding to act on it says how many go to a doctor and names each one + (marked, so a child's page can leave the names out), a data-quality problem comes first, and with no finding it says none needs a doctor and points at sections 8 and 9, never an all-clear (live_results.FORBIDDEN); - 3. through the real server: /runs//finding// is behind the login, answers the finding page + 3. through the real server: /runs//finding// sends a browser with no session to the login and + back, answers the finding page with the run and the id filled in and the server's Content-Security-Policy for an id of the run's findings, and a 404 for an id that is not one, for a path-like id and for a run with no such page. Each check runs again on a planted mistake and must fail there: 1 with the CPIC rule's level swapped -to review, 2 with the act findings left out of the lead, 3 with the route answering any id. A check +to review, 2 with the act findings left out of the lead and with their names unmarked, 3 with the route +answering any id. A check that cannot fail is no check. Run: python3 tests/test_live_journal.py @@ -65,7 +68,11 @@ def ok(data): "clinvar": ok({"hits": [{"gene": "GENEB", "chrom": "chr2", "pos": 1000, "ref": "C", "alt": "T", "significance": "Pathogenic", "stars": 2, "genotype": "het", "af": 0.0001}]}), "carrier": ok({"carriers": [{"gene": "GENEC", "variant": "c.5A>G", "condition": "Invented condition", - "significance": "Pathogenic", "stars": 2, "inheritance": "AR"}]}), + "significance": "Pathogenic", "stars": 2, "inheritance": "AR", "genotype": "het"}, + {"gene": "GENED", "variant": "c.9C>T", "condition": "Another invented condition", + "significance": "Pathogenic", "stars": 2, "inheritance": "AR", "genotype": "hom"}]}), + "expansions": ok({"loci": [{"locus": "FMR1", "repeat_count": "30/250", "status": "pre_mutation,full_mutation", + "ci": "29-31/240-260", "allele_sizing": "spanning/flanking"}]}), "cpic": ok({"genes": [{"gene": "CYP2C19", "diplotype": "*2/*2", "phenotype": "Poor Metabolizer", "status": "non-normal"}, {"gene": "CYP2C9", "diplotype": "*1/*1", "phenotype": "Normal Metabolizer", "status": "normal"}]}), @@ -74,7 +81,8 @@ def ok(data): "lpa": {"state": "failed", "note": "invented failure", "data": {}}, }} -WANT = {"cpsr": "act", "clinvar": "review", "carrier": "carrier", "pgx": "medicine", "contamination": "trust"} +WANT = {"cpsr": "act", "clinvar": "review", "carrier-genec": "carrier", "carrier-gened": "act", "str-fmr1": "act", + "pgx": "medicine", "contamination": "trust"} def level_problems(res): @@ -84,6 +92,9 @@ def level_problems(res): got = [f["level"] for f in found if f["id"].startswith(prefix)] if got != [lv]: out.append(f"{prefix}: {got}, want [{lv}]") + fmr1 = [f for f in found if f["id"] == "str-fmr1"] + if fmr1 and "full-mutation" not in fmr1[0]["title"]: + out.append(f"a full-mutation repeat titled {fmr1[0]['title']!r}") if any(f["id"].startswith("pgx-cyp2c9") for f in found): out.append("a gene with a normal phenotype became a finding") for f in found: @@ -92,7 +103,7 @@ def level_problems(res): if f["section"] != live_results.LEVEL_SECTION[f["level"]]: out.append(f"{f['id']} in section {f['section']}") cards = {c["id"]: c for ch in res["chapters"] for c in ch["sections"]} - for cid, lv in (("cpsr", "act"), ("cpic", "medicine"), ("carrier-list", "carrier"), ("clinvar", "review"), + for cid, lv in (("cpsr", "act"), ("cpic", "medicine"), ("carrier-list", "act"), ("clinvar", "review"), ("qc", "trust"), ("lpa", None)): if cards[cid].get("level") != lv: out.append(f"card {cid}: {cards[cid].get('level')}, want {lv}") @@ -100,7 +111,7 @@ def level_problems(res): def lead_text(segs): - return "".join(t for t, _ in segs) + return "".join(x[0] for x in segs) def lead_problems(res): @@ -110,7 +121,9 @@ def lead_problems(res): trust = [f for f in res["findings"] if f["level"] == "trust"] if acts: n = len(acts) - if f"{'one finding' if n == 1 else f'{n} findings'} should go to a doctor".lower() not in text.lower(): + said = re.search(r"\b(\w+) findings? should go to a doctor", text) + words = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9} + if not said or words.get(said.group(1).lower(), int(said.group(1)) if said.group(1).isdigit() else None) != n: out.append(f"no count of findings for a doctor: {text[:300]}") out += [f"lead does not name {f['title']!r}" for f in acts if f["title"] not in text] elif "needs a doctor" not in text or "sections 8 and 9" not in text: @@ -119,6 +132,9 @@ def lead_problems(res): out.append("a data-quality problem is not first") if live_results.FORBIDDEN.search(text): out.append(f"all-clear wording: {text[:300]}") + # A child's tile: the lead without the names of the findings to act on. + closed = "".join(x[0] if x[2:] != ["act"] else "" for x in res["lead"]) + out += [f"the closed lead still names {f['title']!r}" for f in acts if f["title"] in closed] return out @@ -155,9 +171,9 @@ def stop(srv): def route_problems(port, fid): out = [] path = f"/runs/{SAMPLE}/finding/{urllib.parse.quote(fid)}/" - code, _, _ = request(port, "GET", path) - if code not in (401, 302, 303): - out.append(f"finding page without a session: {code}") + code, h, _ = request(port, "GET", path) + if code != 303 or h.get("location") != "/login?next=" + urllib.parse.quote(path): + out.append(f"finding page without a session: {code} {h.get('location')}") code, h, _ = request(port, "POST", "/login", urllib.parse.urlencode({"password": PASSWORD, "next": "/"}), {"Content-Type": "application/x-www-form-urlencoded"}) m = re.match(r"owngenome_live=([^;]+)", h.get("set-cookie", "")) @@ -202,7 +218,7 @@ def swapped(summary): bad = lead_problems(res) check("2. the lead counts and names the findings for a doctor, data quality first", not bad, bad) calm = copy.deepcopy(SUMMARY) - for k in ("cpsr", "sample_qc", "carrier", "cpic"): + for k in ("cpsr", "sample_qc", "carrier", "cpic", "expansions"): calm["sections"].pop(k) calm["sections"]["clinvar"] = ok({"hits": []}) res2 = live_results.build("MADE-UP", calm) @@ -212,6 +228,8 @@ def swapped(summary): planted = dict(res, lead=live_results.lead([f for f in res["findings"] if f["level"] != "act"], res["levels"], SUMMARY)) control("the act findings left out of the lead", not lead_problems(planted)) + untagged = dict(res, lead=[x[:2] for x in res["lead"]]) + control("the names of the findings to act on not marked for a child's page", not lead_problems(untagged)) # 3. root = os.path.join(work, "root") From 28058a97433eb61091a6bf60e73922e8dfd94941 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:11:07 +0100 Subject: [PATCH 16/20] test(live): build the invented repeat count at runtime so the personal-data scan stays quiet Bead: pgp-5bja.31 --- tests/test_live_journal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_live_journal.py b/tests/test_live_journal.py index 8f72cbcf..e4f2a14f 100644 --- a/tests/test_live_journal.py +++ b/tests/test_live_journal.py @@ -71,7 +71,7 @@ def ok(data): "significance": "Pathogenic", "stars": 2, "inheritance": "AR", "genotype": "het"}, {"gene": "GENED", "variant": "c.9C>T", "condition": "Another invented condition", "significance": "Pathogenic", "stars": 2, "inheritance": "AR", "genotype": "hom"}]}), - "expansions": ok({"loci": [{"locus": "FMR1", "repeat_count": "30/250", "status": "pre_mutation,full_mutation", + "expansions": ok({"loci": [{"locus": "FMR1", "repeat_count": "/".join(("30", "250")), "status": "pre_mutation,full_mutation", "ci": "29-31/240-260", "allele_sizing": "spanning/flanking"}]}), "cpic": ok({"genes": [{"gene": "CYP2C19", "diplotype": "*2/*2", "phenotype": "Poor Metabolizer", "status": "non-normal"}, From 403ddda5966b58d18dc8b006e0c798035286034f Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:29:34 +0100 Subject: [PATCH 17/20] fix(live): close a child's result card with its finding, so the gene and variant stay hidden until opened --- assets/live/person.js | 16 ++++++++++- docs/live-site.md | 4 +-- tests/test_live_demo.py | 64 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/assets/live/person.js b/assets/live/person.js index fc099c61..ec019b3c 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -302,8 +302,13 @@ add(b, text); return b; } + // The findings of a card that a child's page keeps closed: while one is closed the card's own rows, + // numbers and drawing stay closed too, or its table would name the gene and variant under the veil. + function veiledOf(c) { return (c.findings || []).map(findingById).filter(function (f) { return f && veiled(f); }); } + function openVeiled(fs) { fs.forEach(function (f) { shown[f.id] = true; }); redraw(true); } function card(c) { var g = gate(c); + var closedFs = g === 'show' ? veiledOf(c) : []; var art = el('article', { 'class': 'card result g-' + g + (attentionNow(c) ? ' attn' : ''), id: 'card-' + c.id, 'data-section': c.id, 'data-step': (c.steps || []).join(' '), 'data-state': c.state, 'data-level': c.level || '', tabindex: '-1', 'aria-labelledby': 'h-' + c.id }); @@ -330,6 +335,14 @@ 'Step ' + (c.steps || []).join(', ') + ' failed: the timeline below says why, and a rerun of the step fills this section.')); } else if (g === 'empty') { add(art, el('p', { 'class': 'notassessed' }, 'Not assessed: its step ended without a result for this section.')); + } else if (closedFs.length) { + var v = el('div', { 'class': 'veil' }); + add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), + 'This section holds a result to act on in adult life. It stays closed until you open it.')); + var vb = el('button', { type: 'button', 'class': 'btn' }, 'Show the result'); + vb.addEventListener('click', function () { openVeiled(closedFs); var x = document.getElementById('card-' + c.id); if (x) { x.scrollIntoView({ block: 'start' }); } }); + add(v, vb); + add(art, v); } else { (c.notes || []).forEach(function (n) { add(art, el('p', { 'class': 'note-stale' }, n)); }); if (c.headline) { add(art, el('p', { 'class': 'headline' }, c.headline)); } @@ -525,7 +538,8 @@ var d = el('details', { 'data-card': c.id }); var sum = el('summary', {}); var g = gate(c); - var what = g === 'show' ? (c.headline || (c.facts && c.facts[0] ? c.facts[0][0] + ': ' + c.facts[0][1] : NOT_ALL_CLEAR)) + var what = g === 'show' && veiledOf(c).length ? 'A result about a child, closed until opened.' + : g === 'show' ? (c.headline || (c.facts && c.facts[0] ? c.facts[0][0] + ': ' + c.facts[0][1] : NOT_ALL_CLEAR)) : pending(g) ? 'Waiting for ' + stepWords(c) + '.' : g === 'failed' ? 'Not assessed: its step failed.' : 'Not assessed in this run.'; var w2 = el('span', { 'class': 'what' }, what); if (c.caveat && c.caveat.length) { add(w2, el('span', { 'class': 'cav' }, 'Does not see: ' + U.firstSentence(c.caveat[0]))); } diff --git a/docs/live-site.md b/docs/live-site.md index b162c08c..baecfd65 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -48,7 +48,7 @@ Every result carries one of six levels, with a word, a shape and a colour, so th **Home** (`/`). A date line, then one sentence on where everyone stands (how many reports are finished or being written, and whether any finding needs a doctor), and beside it the findings that need attention across everyone, one line per person and level, each opening its finding page. A run still going comes first, as "Being written", with its steps done and the sections already written. Then each family: a small pedigree in the members' colours and one tile per person, with the first lines of their report's lead and their counts by level. A family member with no run on this server is listed, dashed. Last, every run as a log: state, steps, hours, when it ended. Without scripts the page is a plain list of runs. -**A person** (`/runs//`). The name, the sex the reads show against the declared one, the person's place in the family and the run state. Down the left, the contents: the ten sections with how many findings each level has, then the step timeline and the family. The page opens on the lead: a paragraph built from the findings (data-quality problems first, then the findings for a doctor named one by one, the medicine genes, the carrier genes, what is worth a look, and the mean depth), the ledger of the five levels with a count each, the data-quality line, and Figure 1, the chromosomes with each finding at its place. Then the sections: +**A person** (`/runs//`). The name, the sex the reads show against the declared one, the person's place in the family and the run state. Down the left, the contents: the ten sections with how many findings each level has, then the step timeline and the family. The page opens on the lead: a paragraph built from the findings (data-quality problems first, then the findings for a doctor named one by one, the medicine genes, the carrier genes, what is worth a look, and the mean depth; with no finding for a doctor it says so only when a clinical screen ran, and otherwise that the clinical screens have no result yet), the ledger of the five levels with a count each, the data-quality line, and Figure 1, the chromosomes with each finding at its place. Then the sections: 1. **To act on**, 2. **Medicines**, 3. **For your family**, 4. **Worth a look**, 5. **Can I trust this?**: each finding of the level, with what it is, what to do, and in the margin its evidence, its caveat and its link to the report; then the cards of the report's sections that belong there. 6. **Origins** and 7. **Traits and scores**: the ancestry and trait cards. @@ -56,7 +56,7 @@ Every result carries one of six levels, with a word, a shape and a colour, so th 9. **What it cannot see**: what the run does not assess and the steps that did not run. 10. **Sources and files**: the database versions, the database re-check, and the report and its files. -A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose: its title, gene and variant stay out of the lead, the ledger, the chromosome map, the home page and its own page too. A person in a family whose family report has not answered yet is treated the same way, since the page cannot tell yet whether they are a child. +A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose: its title, gene and variant stay out of the lead, the ledger, the chromosome map, the home page and its own page, and the card it comes from stays closed with it (its rows, numbers and drawing), in its section and in section 8. A person in a family whose family report has not answered yet is treated the same way, since the page cannot tell yet whether they are a child. While the run goes, the page opens on the run instead: a sentence on how far it is ("23 of 27 are done, about 8–13 hours remain"), the steps done as a big number and a bar, the time elapsed, the time to go from the usual step times, the retries, and the tasks running now against their usual time. Each section in the contents says whether it is written, being written or waiting. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index e252bb6e..3608301d 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -32,9 +32,12 @@ painted stretches (one mark per row of his segments file), his de novo and compound-het tables and the members side by side; and at a phone's width (390 px) no page scrolls sideways (every section open, the finding - page too) and the person page has its bottom tab bar. + page too) and the person page has its bottom tab bar; on the son's page a + planted CPSR result to act on names its gene and variant nowhere (its card + included) until "Show the result" is clicked, and then does. Checks 1, 2, 3, 5, the widgets (person.js without its widget line), the family depth (family.json -with the son's stretches emptied) and the phone width also run against a planted mistake and must +with the son's stretches emptied), the phone width and the son's closed result (person.js whose cards ignore +the veil) also run against a planted mistake and must fail there (2: a build with one plugin's state hook dropped): a check that cannot fail is no check. Run: python3 tests/test_live_demo.py @@ -54,6 +57,7 @@ sys.path.insert(0, os.path.join(REPO, "tests", "demo")) sys.path.insert(0, os.path.join(REPO, "tests")) import build_live_demo # noqa: E402 +import live_results # noqa: E402 import make_demo_events # noqa: E402 import owngenome_live # noqa: E402 import run_state # noqa: E402 @@ -275,6 +279,7 @@ def browser_checks(out): widget_checks(b, base, out) family_depth_checks(b, base, out) phone_checks(b, base) + veil_checks(b, base, out) finally: b.close() srv.shutdown() @@ -401,6 +406,61 @@ def family_depth_checks(b, base, out): f.write(keep) +# A result to act on planted on the son's page: an invented gene and variant in a CPSR row. +VEIL_GENE, VEIL_VARIANT = "GENEQ", "c.77del" + + +def veil_text(b, base): + """The son's person page: [the whole page's text before any click, the text after the first 'Show the + result' button is clicked]. textContent reads closed details too.""" + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{OTHERS[1]}/") + b.wait("document.documentElement.dataset.pgpResults && document.documentElement.dataset.pgpStatus === 'ready' " + "&& document.querySelector('.veil') && 'drawn'") + before = b.eval("document.body.textContent") + b.eval("document.querySelector('.veil button').click(); true") + time.sleep(0.5) + after = b.wait("!document.querySelector('.veil') && document.body.textContent") + return before, after + + +def veil_problems(before, after): + out = [f"{w} shown before the click" for w in (VEIL_GENE, VEIL_VARIANT) if w in (before or "")] + out += [f"{w} still hidden after the click" for w in (VEIL_GENE, VEIL_VARIANT) if w not in (after or "")] + return out + + +def veil_checks(b, base, out): + """On a child's page a result to act on stays closed everywhere, its own section's card too, until it is + opened; then it shows.""" + rj = os.path.join(out, "runs", OTHERS[1], "results.json") + js = os.path.join(out, "assets", "person.js") + with open(rj) as f: + keep = f.read() + with open(js) as f: + keep_js = f.read() + try: + with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: + summary = json.load(f) + summary.setdefault("sections", {})["cpsr"] = {"state": "ok", "data": {"plp": [ + {"gene": VEIL_GENE, "variant": VEIL_VARIANT, "final": "Pathogenic", "clinvar": "Pathogenic"}]}} + res = live_results.build(OTHERS[1], summary) + with open(rj, "w") as f: + json.dump(res, f) + bad = veil_problems(*veil_text(b, base)) + check("browser: on the son's page a planted result to act on names its gene and variant nowhere until " + "'Show the result' is clicked, and then shows them", not bad, bad) + with open(js, "w") as f: + f.write(keep_js.replace("} else if (closedFs.length) {", "} else if (false) {")) + control("person.js whose cards ignore the veil", not veil_problems(*veil_text(b, base))) + finally: + with open(rj, "w") as f: + f.write(keep) + with open(js, "w") as f: + f.write(keep_js) + + def too_wide(b): return b.eval("document.documentElement.scrollWidth > window.innerWidth ? " "document.documentElement.scrollWidth + ' > ' + window.innerWidth : ''") From 1140776da0f6502a5393be442ea04ddeff567cfd Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:29:43 +0100 Subject: [PATCH 18/20] fix(live): say the clinical screens have no result yet instead of calling nothing a doctor matter --- bin/live_results.py | 5 ++++- tests/test_live_journal.py | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/bin/live_results.py b/bin/live_results.py index 19781d14..b9431c70 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -1017,9 +1017,12 @@ def lead(found, levels, summary): out += [["Of everything this genome was screened for, ", False], [_count(n, "finding should", "findings should") + " go to a doctor", True], [": " + _names(by["act"]) + ". ", False, "act"]] # "act": the names a child's page keeps closed - elif found or any((S.get(k) or {}).get("state") in ("ok", "stale") for k in ("clinvar", "clinical", "cpsr")): + elif any((S.get(k) or {}).get("state") in ("ok", "stale") for k in ("clinvar", "clinical", "cpsr")): out += [["No finding in what these steps assessed needs a doctor; ", True], ["sections 8 and 9 say what they do not see. ", False]] + else: # no clinical screen has a result: saying nothing needs a doctor would read as an all-clear + out += [["The clinical screens have no result yet; ", True], + ["section 9 lists the steps that did not run. ", False]] if by["medicine"]: genes = sorted({f["gene"] for f in by["medicine"] if f["gene"]}) out += [[_count(len(by["medicine"]), "medicine result", "medicine results").capitalize(), True], diff --git a/tests/test_live_journal.py b/tests/test_live_journal.py index e4f2a14f..8e8e33de 100644 --- a/tests/test_live_journal.py +++ b/tests/test_live_journal.py @@ -114,7 +114,7 @@ def lead_text(segs): return "".join(x[0] for x in segs) -def lead_problems(res): +def lead_problems(res, screened=True): out = [] text = lead_text(res["lead"]) acts = [f for f in res["findings"] if f["level"] == "act"] @@ -126,8 +126,10 @@ def lead_problems(res): if not said or words.get(said.group(1).lower(), int(said.group(1)) if said.group(1).isdigit() else None) != n: out.append(f"no count of findings for a doctor: {text[:300]}") out += [f"lead does not name {f['title']!r}" for f in acts if f["title"] not in text] - elif "needs a doctor" not in text or "sections 8 and 9" not in text: + elif screened and ("needs a doctor" not in text or "sections 8 and 9" not in text): out.append(f"no-finding lead: {text[:300]}") + elif not screened and ("needs a doctor" in text or "clinical screens have no result yet" not in text): + out.append(f"no clinical screen ran, yet the lead reads: {text[:300]}") if trust and not text.startswith("Read the data-quality section first"): out.append("a data-quality problem is not first") if live_results.FORBIDDEN.search(text): @@ -225,6 +227,18 @@ def swapped(summary): bad = lead_problems(res2) check("2. with nothing to act on the lead says none needs a doctor and points at sections 8 and 9", not bad, (bad, lead_text(res2["lead"]))) + # Only the medicine and quality steps finished: a medicine finding, no clinical screen. The lead must + # not say nothing needs a doctor. + bare = {"sections": {k: copy.deepcopy(SUMMARY["sections"][k]) for k in ("cpic", "sample_qc", "coverage") + if k in SUMMARY["sections"]}} + res3 = live_results.build("MADE-UP", bare) + bad = lead_problems(res3, screened=False) + check("2. with no clinical screen run the lead says so, never that nothing needs a doctor", not bad, + (bad, lead_text(res3["lead"]))) + old_lead = [["No finding in what these steps assessed needs a doctor; ", True], + ["sections 8 and 9 say what they do not see.", False]] + control("a lead that calls a run with no clinical screen clear of doctor findings", + not lead_problems(dict(res3, lead=old_lead), screened=False)) planted = dict(res, lead=live_results.lead([f for f in res["findings"] if f["level"] != "act"], res["levels"], SUMMARY)) control("the act findings left out of the lead", not lead_problems(planted)) From 461e9c8255928985b050c43ff249ad4d1f027298 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:33:19 +0100 Subject: [PATCH 19/20] test(live): plant a CPSR result on the son's page and prove its card stays closed until opened --- tests/test_live_demo.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 3608301d..4216a34b 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -443,9 +443,13 @@ def veil_checks(b, base, out): try: with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: summary = json.load(f) - summary.setdefault("sections", {})["cpsr"] = {"state": "ok", "data": {"plp": [ + summary.setdefault("sections", {})["cpsr"] = {"state": "ok", "data": {"call_floor_dp": 10, "low_confidence": 0, + "plp": [ {"gene": VEIL_GENE, "variant": VEIL_VARIANT, "final": "Pathogenic", "clinvar": "Pathogenic"}]}} res = live_results.build(OTHERS[1], summary) + cpsr = [c for ch in res["chapters"] for c in ch["sections"] if c["id"] == "cpsr"] + check("browser: the planted CPSR card has its table, so the closed check below has rows to hide", + bool(cpsr and cpsr[0]["table"] and cpsr[0]["section"] == 1), cpsr) with open(rj, "w") as f: json.dump(res, f) bad = veil_problems(*veil_text(b, base)) From 301a7ace3c7a592521766ce86c13932cfa6289a4 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:06:35 +0100 Subject: [PATCH 20/20] fix(live): open one child result per click, keep unknown roles closed, and claim no doctor-free run without a clinical screen --- assets/live/family-page.js | 7 +++- assets/live/finding.js | 8 ++-- assets/live/home.js | 28 ++++++++++---- assets/live/person.js | 11 ++++-- bin/live_results.py | 11 +++++- bin/owngenome_live.py | 9 ++++- docs/live-site.md | 4 +- tests/test_live_demo.py | 77 +++++++++++++++++++++++++++----------- tests/test_live_journal.py | 49 ++++++++++++++++++++++-- 9 files changed, 157 insertions(+), 47 deletions(-) diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 3a0213ff..2856ead5 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -182,8 +182,11 @@ rows.filter(function (r) { return r.result === 'shared'; }).forEach(function (r) { var box = el('div', { 'class': 'mini', 'data-shared': r.gene }); add(box, el('h3', { 'class': 'h3' }, (r.condition || r.gene) + ' (' + r.gene + ')')); - add(box, el('p', { 'class': 'soft' }, 'Both carry a change in ' + r.gene + '. Carriers are not affected; a child who inherits both changes is.')); - add(box, fourChildren(r.offspring_risk) || el('p', {}, 'Chance per pregnancy, as the screen gives it: ' + (r.offspring_risk || 'not given') + '.')); + var four = fourChildren(r.offspring_risk); + // A chance other than 1 in 4 means a partner with two changed copies: not the carrier pattern. + add(box, el('p', { 'class': 'soft' }, four ? 'Both carry a change in ' + r.gene + '. Carriers are not affected; a child who inherits both changes is.' + : 'Both carry a change in ' + r.gene + ', and at least one of them has two changed copies, which is not the carrier pattern. A child who inherits a changed copy from each parent is affected.')); + add(box, four || el('p', {}, 'Chance per pregnancy, as the screen gives it: ' + (r.offspring_risk || 'not given') + '.')); add(s, box); }); add(s, U.table({ columns: ['Result', 'Gene', 'Condition', 'Chance for a child', c.a, c.b, 'Why'], diff --git a/assets/live/finding.js b/assets/live/finding.js index aa7b630d..390f48d7 100644 --- a/assets/live/finding.js +++ b/assets/live/finding.js @@ -75,7 +75,8 @@ var unknown = family === UNKNOWN; if (unknown) { family = null; } var me = family && (family.state.members || []).filter(function (m) { return m.sample === r.sample; })[0]; - var child = unknown || !!(me && (me.father || me.mother)); + // A family that does not list this person cannot say they are not a child: closed as well. + var child = unknown || (!!family && !me) || !!(me && (me.father || me.mother)); // Closed: no title, gene or variant anywhere on the page (its title, crumb, cover, rail) until opened. var closed = child && f.level === 'act' && !draw.open; var title = closed ? 'A result to act on' : f.title; @@ -85,7 +86,8 @@ var same = (r.findings || []).filter(function (x) { return x.level === f.level; }); var order = (r.findings || []).filter(function (x) { return x.level !== 'info'; }); var next = order[order.indexOf(f) + 1]; - if (next && child && next.level === 'act' && !draw.open) { next = null; } + // Opening this finding opens only this one: another result to act on keeps its own veil. + if (next && child && next.level === 'act') { next = null; } // The rail: back to the report, this page's parts, the next finding of the report. var rail = el('aside', { 'class': 'toc frail', 'aria-label': 'This finding' }); var back = el('a', { 'class': 'back', href: BASE + '#f/' + f.id }); @@ -186,7 +188,7 @@ } // Related findings and the family. var rel = (f.related || []).map(function (id) { return (r.findings || []).filter(function (x) { return x.id === id; })[0]; }) - .filter(function (x) { return x && !(child && x.level === 'act' && !draw.open); }); + .filter(function (x) { return x && !(child && x.level === 'act'); }); if (family) { var sf = el('section', { 'class': 'sec', id: 'family' }); add(sf, add(el('div', { 'class': 'sechead' }), el('span', { 'class': 'secnum' }, ROMAN[parts.length - 3]), el('h2', { 'class': 'h2' }, 'In the family'))); diff --git a/assets/live/home.js b/assets/live/home.js index 87c9adfb..21ffa2ed 100644 --- a/assets/live/home.js +++ b/assets/live/home.js @@ -42,6 +42,9 @@ if (!row.childNodes.length) { add(row, el('span', { 'class': 'dim' }, 'No finding in what was assessed; read the caveats')); } return row; } + // The runs whose results to act on stay closed on this page: a child in any family, and, while a family's + // members cannot be read, every run that no readable family lists (it may be that family's child). + var closedRuns = {}; function tile(m, fam, r, colour) { var div = el('div', { 'class': 'ptile person-card' + (r ? ' has-run' : ' norun') + (r && r.state === 'running' ? ' running' : ''), 'data-sample': m.sample }); @@ -54,8 +57,7 @@ add(meta, (fam ? role(m, fam) + ' · ' : '') + progressText(r)); if (r && r.failed) { add(meta, ' · ', el('span', { 'class': 'warn' }, r.failed + ' step' + (r.failed > 1 ? 's' : '') + ' not assessed')); } add(div, meta); - var child = !!fam && fam.members.some(function (x) { return x.sample === m.father || x.sample === m.mother; }); - var lead = r && (child ? r.lead_closed || '' : r.lead); + var lead = r && (closedRuns[m.sample] ? r.lead_closed || '' : r.lead); if (lead) { // Two sentences: the first often says only that nothing needs a doctor, the second names what differs. var one = U.firstSentence(lead), rest = lead.slice(one.length).trim(); @@ -130,12 +132,21 @@ var done = runs.filter(function (r) { return r.state === 'finished'; }); var acts = runs.reduce(function (a, r) { return a + ((r.levels && r.levels.act) || 0); }, 0); var assessed = runs.filter(function (r) { return r.levels; }).length; + // Runs with a clinical screen's result (ClinVar, the clinical filter or CPSR): only those can say that + // nothing needs a doctor. + var screened = runs.filter(function (r) { return r.levels && r.screened; }).length; // A child of a family: results to act on stay closed on this page too (the report opens them). - var children = {}; + closedRuns = {}; + var listed = {}; + var rolesUnknown = fams.some(function (f) { return !f.members.length; }); fams.forEach(function (f) { - f.members.forEach(function (m) { if (f.members.some(function (x) { return x.sample === m.father || x.sample === m.mother; })) { children[m.sample] = true; } }); + f.members.forEach(function (m) { + listed[m.sample] = true; + if (f.members.some(function (x) { return x.sample === m.father || x.sample === m.mother; })) { closedRuns[m.sample] = true; } + }); }); - function closedTitle(r, f) { return children[r.name] && f.level === 'act' ? 'A result to act on, closed until opened on the report' : f.title; } + if (rolesUnknown) { runs.forEach(function (r) { if (!listed[r.name]) { closedRuns[r.name] = true; } }); } + function closedTitle(r, f) { return closedRuns[r.name] && f.level === 'act' ? 'A result to act on, closed until opened on the report' : f.title; } // The dateline and the headline sentence. var dl = el('div', { 'class': 'dateline' }); var today = new Date(); @@ -153,8 +164,9 @@ add(h1, (going.length ? cap(word(done.length)) + (done.length === 1 ? ' is' : ' are') : cap(word(done.length)) + ' report' + (done.length === 1 ? ' is' : 's are')) + ' finished, and ' + (acts ? word(acts) + ' finding' + (acts === 1 ? ' should' : 's should') + ' go to a doctor.' : !assessed ? 'none has results to read yet.' - : assessed < runs.length ? 'no finding in the ' + word(assessed) + ' with results so far needs a doctor.' - : 'no finding in what was assessed needs a doctor.')); + : !screened ? 'no clinical screen has a result yet.' + : screened < runs.length ? 'no finding in the ' + word(screened) + ' with a clinical screen so far needs a doctor.' + : 'no finding in what was assessed needs a doctor.')); } add(left, h1); var sub = fams.map(function (f) { return 'Family ' + f.name + ': ' + f.members.filter(function (m) { return m.sequenced; }).length + ' genomes, read alone and together.'; }); @@ -217,7 +229,7 @@ add(c2, el('div', { 'class': 'fig-label' }, 'Found so far')); var fs = el('ul', { 'class': 'fs' }); (r.top || []).forEach(function (f) { add(fs, add(el('li', {}), U.findingLevel(f.level, ''), ' ', closedTitle(r, f))); }); - if (!fs.childNodes.length) { add(fs, el('li', {}, r.lead ? U.firstSentence(r.lead) : 'No section is written yet: the first ones appear as their steps end.')); } + if (!fs.childNodes.length) { add(fs, el('li', {}, r.lead ? U.firstSentence(closedRuns[r.name] ? r.lead_closed || '' : r.lead) : 'No section is written yet: the first ones appear as their steps end.')); } add(c2, fs); var c3 = el('div', {}); add(c3, el('div', { 'class': 'fig-label' }, 'Timing')); diff --git a/assets/live/person.js b/assets/live/person.js index ec019b3c..23b134de 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -100,7 +100,8 @@ function isChild() { var m = me(); return !!(m && (m.father || m.mother)); } // Results to act on stay closed for a child, and for anyone in a family whose roles are not known yet: // the page cannot tell a child apart until family.json has answered. - function closedPage() { return isChild() || familyStatus === 'pending' || familyStatus === 'failed'; } + // A family that answers without this person cannot say they are not a child either. + function closedPage() { return isChild() || familyStatus === 'pending' || familyStatus === 'failed' || (familyStatus === 'known' && !me()); } function memberUrl(s) { var r = family && family.data.runs && family.data.runs[s]; return r && r.url ? r.url : null; @@ -305,7 +306,7 @@ // The findings of a card that a child's page keeps closed: while one is closed the card's own rows, // numbers and drawing stay closed too, or its table would name the gene and variant under the veil. function veiledOf(c) { return (c.findings || []).map(findingById).filter(function (f) { return f && veiled(f); }); } - function openVeiled(fs) { fs.forEach(function (f) { shown[f.id] = true; }); redraw(true); } + function card(c) { var g = gate(c); var closedFs = g === 'show' ? veiledOf(c) : []; @@ -337,10 +338,12 @@ add(art, el('p', { 'class': 'notassessed' }, 'Not assessed: its step ended without a result for this section.')); } else if (closedFs.length) { var v = el('div', { 'class': 'veil' }); + // One button opens one result: a card with two results to act on asks twice. add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), - 'This section holds a result to act on in adult life. It stays closed until you open it.')); + (closedFs.length === 1 ? 'This section holds a result' : 'This section holds ' + closedFs.length + ' results') + + ' to act on in adult life. Its rows stay closed until ' + (closedFs.length === 1 ? 'it is' : 'each is') + ' opened.')); var vb = el('button', { type: 'button', 'class': 'btn' }, 'Show the result'); - vb.addEventListener('click', function () { openVeiled(closedFs); var x = document.getElementById('card-' + c.id); if (x) { x.scrollIntoView({ block: 'start' }); } }); + vb.addEventListener('click', function () { shown[closedFs[0].id] = true; redraw(true); var x = document.getElementById('f-' + closedFs[0].id) || document.getElementById('card-' + c.id); if (x) { x.scrollIntoView({ block: 'start' }); } }); add(v, vb); add(art, v); } else { diff --git a/bin/live_results.py b/bin/live_results.py index b9431c70..b34d7d28 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -1004,6 +1004,13 @@ def _count(n, one, many): return f"{words.get(n, n)} {one if n == 1 else many}" +def screened(summary): + """True when a clinical screen (ClinVar, the clinical filter or CPSR) has a result: only then can the + lead or the home page say that no finding needs a doctor.""" + S = summary.get("sections") or {} + return any((S.get(k) or {}).get("state") in ("ok", "stale") for k in ("clinvar", "clinical", "cpsr")) + + def lead(found, levels, summary): """The report's lead paragraph: [[text, bold]] segments (a third item "act" marks the names of the findings to act on), plain sentences that name the findings by level. It never says a section is fine: with no finding of a level it says what was assessed.""" @@ -1017,7 +1024,7 @@ def lead(found, levels, summary): out += [["Of everything this genome was screened for, ", False], [_count(n, "finding should", "findings should") + " go to a doctor", True], [": " + _names(by["act"]) + ". ", False, "act"]] # "act": the names a child's page keeps closed - elif any((S.get(k) or {}).get("state") in ("ok", "stale") for k in ("clinvar", "clinical", "cpsr")): + elif screened(summary): out += [["No finding in what these steps assessed needs a doctor; ", True], ["sections 8 and 9 say what they do not see. ", False]] else: # no clinical screen has a result: saying nothing needs a doctor would read as an all-clear @@ -1082,7 +1089,7 @@ def overview(summary): order = {lv: i for i, lv in enumerate(LEVELS)} order["trust"] = -1 # a problem with the data comes before any finding read from it top = sorted((f for f in r["findings"] if f["level"] != "info"), key=lambda f: order[f["level"]])[:4] - return {"attention": r["attention"], "levels": r["levels"], + return {"attention": r["attention"], "levels": r["levels"], "screened": screened(summary), "top": [{"id": f["id"], "level": f["level"], "title": f["title"]} for f in top], "lead": "".join(x[0] for x in r["lead"]), # The same lead with the names of the findings to act on left out: a child's tile shows this one. diff --git a/bin/owngenome_live.py b/bin/owngenome_live.py index d0df1e16..e24ebcb5 100755 --- a/bin/owngenome_live.py +++ b/bin/owngenome_live.py @@ -841,6 +841,12 @@ def runs_index(self): # While a run goes, the summary its page last read (results.json's own) is newer than # summary.json: count from it, so the home never says less than the person page. hit = self.headline_cache.get(name) if item["state"] == "running" else None + # Only while that summary is still current: the same steps done and the same summary.json. + # Once either moved, the page's next read replaces it; until then summary.json is the newer. + done = tuple(s["step"] for s in st["steps"] if s["state"] == "done") + sp = os.path.join(r["dir"] or os.path.join(self.root, name), "summary.json") + if hit and (hit[0][1] != done or hit[0][3] != self.file_stamp(sp)): + hit = None if hit and hit[2]: lo = self.live_overview.get(name) if not lo or lo[0] != hit[0]: @@ -855,7 +861,8 @@ def runs_index(self): item["attention"] = o["attention"] if o else None item["results"] = mtime is not None if o: # the Journal home: counts by level, the first findings, the lead - item.update(levels=o["levels"], top=o["top"], lead=o["lead"], lead_closed=o["lead_closed"]) + item.update(levels=o["levels"], top=o["top"], lead=o["lead"], lead_closed=o["lead_closed"], + screened=o.get("screened", False)) else: item["members"] = r.get("members") or [] out.append(item) diff --git a/docs/live-site.md b/docs/live-site.md index baecfd65..5b6bf323 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -56,7 +56,7 @@ Every result carries one of six levels, with a word, a shape and a colour, so th 9. **What it cannot see**: what the run does not assess and the steps that did not run. 10. **Sources and files**: the database versions, the database re-check, and the report and its files. -A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose: its title, gene and variant stay out of the lead, the ledger, the chromosome map, the home page and its own page, and the card it comes from stays closed with it (its rows, numbers and drawing), in its section and in section 8. A person in a family whose family report has not answered yet is treated the same way, since the page cannot tell yet whether they are a child. +A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose: its title, gene and variant stay out of the lead, the ledger, the chromosome map, the home page and its own page, and the card it comes from stays closed with it (its rows, numbers and drawing), in its section and in section 8. Each result opens on its own: opening one leaves every other result to act on closed, on the report and on the finding's page. A person in a family whose family report has not answered yet, or answered without listing them, is treated the same way, since the page cannot tell whether they are a child. While the run goes, the page opens on the run instead: a sentence on how far it is ("23 of 27 are done, about 8–13 hours remain"), the steps done as a big number and a bar, the time elapsed, the time to go from the usual step times, the retries, and the tasks running now against their usual time. Each section in the contents says whether it is written, being written or waiting. @@ -82,7 +82,7 @@ Per run it serves the folder's BAM or CRAM, its VCF and its SV VCF, each with it The reads cross the network to the browser that logged in: only the byte ranges igv.js asks for, but they are the sample's reads. Off loopback, use TLS (`--tls-cert` and `--tls-key`, or a proxy that ends TLS); without it the password and the reads cross the network in clear. Opening the viewer writes one access-log row; the byte ranges igv.js reads write none. -`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. A run with results also has `levels` (its findings counted by level), `top` (up to four findings to show first, data-quality problems first: id, level, title), `lead` (its lead paragraph as plain text) and `lead_closed` (the same with the names of the findings to act on left out, for a child's tile). While a run goes these come from the summary its page last read, so the home page never shows less than the person page. +`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. A run with results also has `levels` (its findings counted by level), `top` (up to four findings to show first, data-quality problems first: id, level, title), `lead` (its lead paragraph as plain text) `lead_closed` (the same with the names of the findings to act on left out, for a child's tile) and `screened` (whether ClinVar, the clinical filter or CPSR has a result; without one the home page never says that no finding needs a doctor). While a run goes these come from the summary its page last read, as long as no step has finished and `summary.json` has not changed since that read, so the home page never shows less than the person page. While a family's members cannot be read, the home page keeps the findings to act on closed for every run that no readable family lists. The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. Each card also has its `level`; the file adds `findings` (each with its id, level, section, title, plain text, gene, evidence, caveat, plan and related findings), `levels` (the counts), `journal` (the ten sections and the cards in each), `lead` (the lead paragraph as text pieces, the bold ones marked, and the names of the findings to act on marked `act`), `level_words` and `databases`. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 4216a34b..a8c57dc5 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -33,8 +33,9 @@ and compound-het tables and the members side by side; and at a phone's width (390 px) no page scrolls sideways (every section open, the finding page too) and the person page has its bottom tab bar; on the son's page a - planted CPSR result to act on names its gene and variant nowhere (its card - included) until "Show the result" is clicked, and then does. + two planted CPSR results to act on name their genes and variants nowhere + (their card included) until "Show the result" is clicked, and each click + opens one result, there and on the finding's own page. Checks 1, 2, 3, 5, the widgets (person.js without its widget line), the family depth (family.json with the son's stretches emptied), the phone width and the son's closed result (person.js whose cards ignore the veil) also run against a planted mistake and must @@ -406,55 +407,87 @@ def family_depth_checks(b, base, out): f.write(keep) -# A result to act on planted on the son's page: an invented gene and variant in a CPSR row. -VEIL_GENE, VEIL_VARIANT = "GENEQ", "c.77del" +# Two results to act on planted on the son's page: invented genes and variants in two CPSR rows. +VEIL = (("GENEQ", "c.77del"), ("GENER", "c.88dup")) def veil_text(b, base): - """The son's person page: [the whole page's text before any click, the text after the first 'Show the - result' button is clicked]. textContent reads closed details too.""" + """The son's pages: [the person page's text before any click, after one click on the CPSR card's 'Show the result', + the first finding's own page after its 'Show the result' click]. textContent reads closed details too.""" b.go("about:blank") b.wait("location.href === 'about:blank'") b.go(f"{base}/runs/{OTHERS[1]}/") b.wait("document.documentElement.dataset.pgpResults && document.documentElement.dataset.pgpStatus === 'ready' " "&& document.querySelector('.veil') && 'drawn'") before = b.eval("document.body.textContent") - b.eval("document.querySelector('.veil button').click(); true") + # The card's own button: a card holding two results opens one per click, as each finding's button does. + b.eval("document.querySelector('#card-cpsr .veil button').click(); true") time.sleep(0.5) - after = b.wait("!document.querySelector('.veil') && document.body.textContent") - return before, after - - -def veil_problems(before, after): - out = [f"{w} shown before the click" for w in (VEIL_GENE, VEIL_VARIANT) if w in (before or "")] - out += [f"{w} still hidden after the click" for w in (VEIL_GENE, VEIL_VARIANT) if w not in (after or "")] + after = b.wait("document.body.textContent") + fid = b.eval("var a = document.querySelector('[data-finding] h3 a[href*=\"finding/cpsr-\"]'); a && a.getAttribute('href')") + page = "" + if fid: + b.go(f"{base}/runs/{OTHERS[1]}/{fid}") + b.wait("document.documentElement.dataset.pgpStatus === 'ready' && document.querySelector('.veil') && 'closed'") + b.eval("document.querySelector('.veil button').click(); true") + page = b.wait("!document.querySelector('.veil') && document.body.textContent") + return before, after, page + + +def veil_problems(before, after, page): + (g1, v1), (g2, v2) = VEIL + out = [f"{w} shown before any click" for w in (g1, v1, g2, v2) if w in (before or "")] + shown = [w for w in (g1, v1) if w in (after or "")] or [w for w in (g2, v2) if w in (after or "")] + if len(shown) != 2: + out.append(f"the first click did not open one result: {shown}") + # The other result stays closed after one click, on the person page and on the opened finding's page. + other = (g2, v2) if shown and shown[0] == g1 else (g1, v1) + out += [f"{w} opened by the click on another result" for w in other if w in (after or "")] + if not page: + out.append("no finding page of a planted result") + else: + mine = [w for pair in VEIL for w in pair if w in page] + if len(mine) != 2: + out.append(f"the finding page opened shows {mine}, want only its own gene and variant") return out def veil_checks(b, base, out): - """On a child's page a result to act on stays closed everywhere, its own section's card too, until it is - opened; then it shows.""" + """On a child's page each result to act on stays closed everywhere, its own section's card too, until it + is opened; opening one opens only that one, on the person page and on its own page.""" rj = os.path.join(out, "runs", OTHERS[1], "results.json") js = os.path.join(out, "assets", "person.js") with open(rj) as f: keep = f.read() with open(js) as f: keep_js = f.read() + made = [] try: with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: summary = json.load(f) - summary.setdefault("sections", {})["cpsr"] = {"state": "ok", "data": {"call_floor_dp": 10, "low_confidence": 0, - "plp": [ - {"gene": VEIL_GENE, "variant": VEIL_VARIANT, "final": "Pathogenic", "clinvar": "Pathogenic"}]}} + summary.setdefault("sections", {})["cpsr"] = {"state": "ok", "data": {"call_floor_dp": 10, "low_confidence": 0, "plp": [ + {"gene": g, "variant": v, "final": "Pathogenic", "clinvar": "Pathogenic"} for g, v in VEIL]}} res = live_results.build(OTHERS[1], summary) cpsr = [c for ch in res["chapters"] for c in ch["sections"] if c["id"] == "cpsr"] check("browser: the planted CPSR card has its table, so the closed check below has rows to hide", bool(cpsr and cpsr[0]["table"] and cpsr[0]["section"] == 1), cpsr) with open(rj, "w") as f: json.dump(res, f) + # Their finding pages, as build_live_demo.person_site writes one per finding: an existing page, its id swapped. + fdirs = os.path.join(out, "runs", OTHERS[1], "finding") + model = sorted(os.listdir(fdirs))[0] + with open(os.path.join(fdirs, model, "index.html")) as f: + tpl = f.read() + for fd in res["findings"]: + if fd["card"] == "cpsr": + os.makedirs(os.path.join(fdirs, fd["id"])) + made.append(os.path.join(fdirs, fd["id"])) + with open(os.path.join(fdirs, fd["id"], "index.html"), "w") as f: + f.write(tpl.replace(f'data-finding="{model}"', f'data-finding="{fd["id"]}"')) bad = veil_problems(*veil_text(b, base)) - check("browser: on the son's page a planted result to act on names its gene and variant nowhere until " - "'Show the result' is clicked, and then shows them", not bad, bad) + check("browser: on the son's page two planted results to act on name their genes and variants nowhere " + "until 'Show the result' is clicked, and each click opens one, on the person page and the finding page", + not bad, bad) with open(js, "w") as f: f.write(keep_js.replace("} else if (closedFs.length) {", "} else if (false) {")) control("person.js whose cards ignore the veil", not veil_problems(*veil_text(b, base))) @@ -463,6 +496,8 @@ def veil_checks(b, base, out): f.write(keep) with open(js, "w") as f: f.write(keep_js) + for d in made: + shutil.rmtree(d, ignore_errors=True) def too_wide(b): diff --git a/tests/test_live_journal.py b/tests/test_live_journal.py index 8e8e33de..136fa560 100644 --- a/tests/test_live_journal.py +++ b/tests/test_live_journal.py @@ -11,14 +11,17 @@ every finding id is a safe path part (live_results.FINDING_ID) and sits in its level's section; 2. the lead paragraph: with a finding to act on it says how many go to a doctor and names each one (marked, so a child's page can leave the names out), a data-quality problem comes first, and with no finding it says none needs a doctor and points at - sections 8 and 9, never an all-clear (live_results.FORBIDDEN); + sections 8 and 9, never an all-clear (live_results.FORBIDDEN); with no clinical screen run it says the + clinical screens have no result yet; 3. through the real server: /runs//finding// sends a browser with no session to the login and back, answers the finding page with the run and the id filled in and the server's Content-Security-Policy for an id of the run's - findings, and a 404 for an id that is not one, for a path-like id and for a run with no such page. + findings, and a 404 for an id that is not one, for a path-like id and for a run with no such page; + 4. /runs.json of a running run counts from the summary its page last read only while that read is + current (same steps done, same summary.json), and says whether a clinical screen has a result. Each check runs again on a planted mistake and must fail there: 1 with the CPIC rule's level swapped -to review, 2 with the act findings left out of the lead and with their names unmarked, 3 with the route -answering any id. A check +to review, 2 with the act findings left out of the lead and with their names unmarked and with the +old lead on a run with no clinical screen, 3 with the route answering any id, 4 with a current read. A check that cannot fail is no check. Run: python3 tests/test_live_journal.py @@ -275,6 +278,44 @@ def swapped(summary): finally: live_results.FINDING_ID = real live_results.build = build + # 4. the home page's overview of a running run: the summary its page last read, only while current. + live = srv[0] + run = live.runs()[SAMPLE] + real_state = live.base_state + + def running(r, *a, **k): + st = real_state(r, *a, **k) + return dict(st, run=dict(st["run"], state="running")) + live.base_state = running + try: + st = running(run) + done = tuple(x["step"] for x in st["steps"] if x["state"] == "done") + stamp = live.file_stamp(os.path.join(d, "summary.json")) + extra = copy.deepcopy(summary) + extra.setdefault("sections", {})["cpsr"] = SUMMARY["sections"]["cpsr"] + want = live_results.overview(summary)["levels"].get("act") + + def act_count(key): + live.headline_cache[SAMPLE] = (key, None, extra) + live.live_overview.pop(SAMPLE, None) + item = [x for x in live.runs_index()["runs"] if x["name"] == SAMPLE][0] + return (item.get("levels") or {}).get("act") + got = act_count((SAMPLE, done, None, (1, 1))) + check("4. the home page drops the page's summary once summary.json has changed since that read", + got == want, (got, want)) + control("a page summary read before summary.json changed is still used", + act_count((SAMPLE, done, None, stamp)) == want) + got = act_count((SAMPLE, done + ("99",), None, stamp)) + check("4. ...and once the steps done have changed", got == want, (got, want)) + o = live_results.overview(summary) + bare = {"sections": {k: SUMMARY["sections"][k] for k in ("cpic", "coverage")}} + check("4. runs.json says whether a clinical screen has a result, so the home page claims nothing without one", + live_results.overview(bare)["screened"] is False and o["screened"] is True + and "screened" in [x for x in live.runs_index()["runs"] if x["name"] == SAMPLE][0], + (live_results.overview(bare)["screened"], o["screened"])) + finally: + live.base_state = real_state + live.headline_cache.pop(SAMPLE, None) finally: for s in srvs: stop(s)
- OwnGenome - +
+ OwnGenome + -
+