From 63721408f44d3adee5f292260958f5c14eade95c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:55:47 +0100 Subject: [PATCH 01/27] test(live): a child's closed result must stay off the family page Red first: the demo family's compound-het pair in KCNH2 names the son's planted result to act on, and the family page shows it open. The new browser checks also plant a de novo candidate and a grandparent stretch over it, and check the son's copy-number dot in a closed gene. The unit checks want results.json to carry the closed results (veil.spans), facts and drawing items tagged, and core.js veilId() to answer as veil_id(). Bead: pgp-iqon.24 --- tests/demo/make_demo_sample.py | 3 +- .../live/demo/DEMO-FAMILY/compound_het.tsv | 1 + tests/fixtures/live/demo/README.md | 2 + tests/test_live_demo.py | 219 +++++++++++++++++- tests/test_live_results.py | 93 +++++++- 5 files changed, 312 insertions(+), 6 deletions(-) diff --git a/tests/demo/make_demo_sample.py b/tests/demo/make_demo_sample.py index 9def678d8..352eb0b93 100755 --- a/tests/demo/make_demo_sample.py +++ b/tests/demo/make_demo_sample.py @@ -107,7 +107,8 @@ def own_chroms(): FINDINGS = {"DEMO-002": {"fmr1", "chip", "mocha"}, "DEMO-003": {"annotsv", "act"}} # The son's results to act on ("act"): a pathogenic ClinVar hit in KCNH2, an ACMG secondary-findings gene that # is on no carrier list, and an AnnotSV class 5 deletion over it. Public gene name, invented positions and -# alleles. tests/test_live_demo.py checks that his page names neither until each is opened on purpose. +# alleles. tests/test_live_demo.py checks that his page names neither until each is opened on purpose, and that the +# family page keeps closed the compound-het pair in KCNH2 of tests/fixtures/live/demo/DEMO-FAMILY too. PLANTED = {"gene": "KCNH2", "hit": ("chr7", 150960000, "C", "T"), "sv": ("chr7", 150955000, 150965000)} # 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), diff --git a/tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv b/tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv index a5927bf30..5a68b490d 100644 --- a/tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv +++ b/tests/fixtures/live/demo/DEMO-FAMILY/compound_het.tsv @@ -1,3 +1,4 @@ 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 +DEMO-003 KCNH2 chr7:150960000:C>T chr7:150962400:G>A father mother in trans diff --git a/tests/fixtures/live/demo/README.md b/tests/fixtures/live/demo/README.md index 62ca22167..d08c2eade 100644 --- a/tests/fixtures/live/demo/README.md +++ b/tests/fixtures/live/demo/README.md @@ -14,5 +14,7 @@ sample DEMO-001 that `tests/demo/make_demo_sample.py` writes. No real sample, pe - `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. + `compound_het.tsv` also holds a pair in KCNH2 whose first variant is the son's planted result to act on + (`make_demo_sample.py` PLANTED): the family page keeps that row closed until the result is opened there. The run itself is `../demo-001.weblog.jsonl`, written by `tests/demo/make_demo_events.py`. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 805606cfb..8f10fe575 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -53,7 +53,15 @@ marks and ranking only, and a reload closes them again; the home page and every finding page of the son (an open finding's rail and related list too) name them nowhere, and the symptom ranking's own page shows the gene - once opened on purpose; DEMO-001's page + once opened on purpose; on the family page the son's rows that name a planted + result (the compound-het pair in KCNH2 of the fixture, and a de novo + candidate and a stretch painted from a grandparent planted in family.json) + name its gene, positions and that grandparent in no row, link, title or + count until "Show the result" is clicked there, the click brings each row + back, and a reload closes them again; with the son's results.json + unavailable every row of his that could name one stays closed; on his page + a CHIP call and a copy-number dot in the gene of a closed result stay out + of their drawings until it is opened; DEMO-001's page mid-replay and at the end leads with the report (a short run band above the lead while it runs, none once finished), ends with the step timeline folded, keeps the wire to at most eight one-sentence lines with the plain @@ -64,7 +72,8 @@ the veil, person.js and finding.js that read a missing state as a standalone adult, and search.js without its filter; for the planted results, results.json without its veil tags, person.js whose table rows ignore them, person.js whose findings ignore them, charts.js that keeps every mark, search.js that ignores a hit's tag -and finding.js whose findings ignore their tags), the phone page (person.js that never folds, and a route that opens only the nearest fold), the +and finding.js whose findings ignore their tags; for the family page, family-page.js whose rows ignore the veil +and core.js whose veilId() tags nothing; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the folds (a flagged section 5 card folded, a redraw that forgets the folds, a Ctrl+click that opens one) and the person page's layout (a wire showing every line or every plain finish, a card showing every line, a step timeline left open) also run against a planted mistake and must @@ -314,6 +323,8 @@ def browser_checks(out): fold_checks(b, base, out) veil_checks(b, base, out) planted_checks(b, base, out) + family_veil_checks(b, base, out) + widget_veil_checks(b, base, out) finally: b.close() srv.shutdown() @@ -408,7 +419,8 @@ 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([" + return b.wait("document.documentElement.dataset.pgpStatus === 'ready' && " + "(document.documentElement.dataset.pgpKids || '').indexOf('DEMO-003:known') >= 0 && 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, " @@ -716,6 +728,207 @@ def write(p, text): write(p, text) +# The family page: the son's rows that name his planted result to act on. +FAMILY_SHOWN_JS = ("[document.body.textContent].concat(Array.prototype.map.call(document.querySelectorAll('[href],[title],[aria-label]," + "[alt]'), function (e) { return ['href', 'title', 'aria-label', 'alt'].map(function (k) { " + "return e.getAttribute(k) || ''; }).join(' '); })).join(' ')") +GRANDPARENT = "DEMO-GF" +PAIR_B = "150962400" # the second variant of the fixture's compound-het pair in KCNH2 + + +def no_cache(b): + """Chrome keeps a script it fetched a moment ago without asking again: the controls below swap a script, so + every request goes to the server from here on.""" + b.call("Network.enable", {}, b.session) + b.call("Network.setCacheDisabled", {"cacheDisabled": True}, b.session) + + +def family_page(b, base): + """The family page once it knows the son's results (or after 10 s, for a page that never learns them).""" + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{FAM}/") + b.wait("document.documentElement.dataset.pgpStatus === 'ready' && " + "/DEMO-003:(known|failed)/.test(document.documentElement.dataset.pgpKids || '') && 'ready'", 10) + time.sleep(0.3) + + +def family_closed(b, base): + """[problems] of the family page before any click: the son's planted gene, its positions or the grandparent of + the stretch over it named anywhere; the rows that name nothing closed gone too.""" + gene, hit, sv = planted_words() + family_page(b, base) + shown = b.eval(FAMILY_SHOWN_JS) or "" + out = [f"{w} shown on the family page while closed" for w in [gene, GRANDPARENT, PAIR_B] + hit + sv[:1] if w in shown] + rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " + "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") + closed = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") + segs = b.eval("document.querySelectorAll('[data-painting=\"DEMO-003\"] .paint-seg.closed').length") + # The stretches' table, the 2 de novo rows of the fixture and its 2 other pairs stay; one closed box each. + if not rows or json.loads(rows)[1:] != [2, 2] or closed != 3 or segs != 1: + out.append(f"the open rows and the closed boxes: tables {rows}, {closed} closed boxes, {segs} closed stretches") + return out + + +def family_problems(b, base): + """[problems] of the family page: closed, then opened with one click, then reloaded.""" + gene, hit, _ = planted_words() + out = family_closed(b, base) + if out: + return out + b.eval("document.querySelector('details[data-child=\"DEMO-003\"] .veil button').click(); true") + time.sleep(0.5) + shown = b.eval(FAMILY_SHOWN_JS) or "" + rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " + "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") + left = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") + if not all(w in shown for w in [gene, GRANDPARENT, hit[0]]) or json.loads(rows or "[0,0,0]")[1:] != [3, 3] or left: + out.append(f"one click on 'Show the result' did not bring back each row: tables {rows}, {left} closed boxes left") + out += [f"after a reload: {x}" for x in family_closed(b, base)] + return out + + +def family_unknown(b, base): + """[problems] of the family page with the son's results.json unavailable: a row of his named, or any of his de + novo or compound-het rows open.""" + gene, hit, sv = planted_words() + family_page(b, base) + shown = b.eval(FAMILY_SHOWN_JS) or "" + out = [f"{w} shown with the son's results unavailable" for w in [gene, GRANDPARENT, PAIR_B] + hit + sv[:1] if w in shown] + rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " + "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") + if rows is None or len(json.loads(rows)) != 1: + out.append(f"the son's de novo or compound-het rows open with his results unavailable: tables {rows}") + return out + + +def family_veil_checks(b, base, out): + """On the family page a child's rows that name a closed result stay closed as on the child's page, until that + result is opened there; a page that cannot read the child's results keeps every row of his that could closed.""" + no_cache(b) + import make_demo_sample + fj = os.path.join(out, "runs", FAM, "family.json") + rj = os.path.join(out, "runs", OTHERS[1], "results.json") + fp, cj = (os.path.join(out, "assets", x) for x in ("family-page.js", "core.js")) + keep = {} + for p in (fj, fp, cj): + with open(p) as f: + keep[p] = f.read() + + def write(p, text): + with open(p, "w") as f: + f.write(text) + try: + # A de novo candidate at the planted hit, and the father's copy over it painted from a grandparent. + fam = json.loads(keep[fj]) + c, pos, ref, alt = make_demo_sample.PLANTED["hit"] + row = dict(fam["de_novo"][0], chrom=c, pos=str(pos), ref=ref, alt=alt) + fam["de_novo"].append(row) + segs = fam["segments"]["DEMO-003"] + over = [s_ for s_ in segs if s_["chrom"] == c and s_["side"] == "paternal" and int(s_["start"]) <= pos <= int(s_["end"])] + if len(over) != 1: + check("browser: the son's stretches hold one paternal stretch over the planted hit", False, over) + return + over[0]["ancestor"] = GRANDPARENT + write(fj, json.dumps(fam)) + keep["planted"] = json.dumps(fam) + bad = family_problems(b, base) + check("browser: on the family page the son's rows that name his planted result (a compound-het pair, a de novo " + "candidate, a stretch from a grandparent) name its gene, positions and grandparent nowhere until 'Show the " + "result' is clicked there, one click brings each row back, and a reload closes them", not bad, bad) + for p, a, z, desc in ( + (fp, "function isClosed(child, tag) { return !!tag && ", "function isClosed(child, tag) { return false && ", + "family-page.js whose rows ignore the veil"), + (cj, " if (!spans || !spans.length) { return null; }", " return null;", "core.js whose veilId() tags nothing")): + if a not in keep[p]: + check(f"control: {desc} (the planted line is in {os.path.basename(p)})", False, a) + continue + write(p, keep[p].replace(a, z)) + control(desc, not family_closed(b, base)) + write(p, keep[p]) + os.replace(rj, rj + ".away") + try: + bad = family_unknown(b, base) + finally: + os.replace(rj + ".away", rj) + check("browser: with the son's results.json unavailable the family page keeps every row of his that could name " + "a result to act on closed", not bad, bad) + finally: + keep.pop("planted", None) + for p, text in keep.items(): + write(p, text) + + +def widget_veil_checks(b, base, out): + """On the son's page a CHIP call and a copy-number dot in the gene of a closed result stay out of sight until that + result is opened; the other dot stays drawn. A CHIP call is also a finding of its card that names its gene, so + the CHIP card closes whole with the result; SMN1 at two copies is no finding, so only its dot's tag closes it.""" + no_cache(b) + 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() + + def drawn(): + 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('[data-widget=copies]') && document.querySelector('.veil') && 'drawn'", 15) + return shown() + + def shown(): + return b.eval("JSON.stringify([" + SHOWN_JS + ", (document.querySelector('[data-widget=copies]') || {}).textContent || ''])") + + def problems(text, opened): + page, cops = json.loads(text) if text else ["", ""] + out_ = [] + if "SMN2" not in cops: + out_.append(f"the open dot is not drawn: {cops[:120]}") + if ("p.R175H" in page) != opened or ("SMN1" in cops) != opened: + out_.append(f"the CHIP call or the SMN1 dot of the closed results {'not ' if opened else ''}shown: " + f"{'p.R175H' in page} | {cops[:200]}") + return out_ + try: + with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: + summary = json.load(f) + S = summary.setdefault("sections", {}) + S["cpsr"] = {"state": "ok", "data": {"call_floor_dp": 10, "low_confidence": 0, "plp": [ + {"gene": "TP53", "variant": "c.524G>A", "final": "Pathogenic", "clinvar": "Pathogenic"}, + {"gene": "SMN1", "variant": "c.1A>G", "final": "Pathogenic", "clinvar": "Pathogenic"}]}} + S["chip"] = {"state": "ok", "data": {"median_limit": 0.05, "median_depth": 72, "calls": [ + {"gene": g, "protein_change": ch, "vaf": v, "alt_reads": 6, "depth": 75, "status": "pass", "note": "a clone"} + for g, ch, v in (("DNMT3A", "p.R882H", 0.12), ("TP53", "p.R175H", 0.08))], + "genes": [{"gene": g, "detection_limit_vaf": 0.05, "mean_depth": 75, "status": "ok"} for g in ("DNMT3A", "TP53")]}} + S["paralogs"] = {"state": "ok", "data": {"genes": [{"gene": g, "copy_number": n, "status": "called", "detail": "."} + for g, n in (("SMN1", 2), ("SMN2", 2))]}} + res = live_results.build(OTHERS[1], summary) + with open(rj, "w") as f: + json.dump(res, f) + bad = problems(drawn(), False) + if not bad: + for _ in range(2): # the CPSR card holds both results: one click opens one + b.eval("document.querySelector('#card-cpsr .veil button').click(); true") + time.sleep(0.5) + bad = problems(shown(), True) + check("browser: on the son's page a CHIP call and a copy-number dot in the gene of a closed result stay out of " + "sight until it is opened", not bad, bad) + a = "add(text, widget(of[1]));" + if a not in keep_js: + check("control: person.js that draws every item (the planted line is in person.js)", False, a) + else: + with open(js, "w") as f: + f.write(keep_js.replace(a, "add(text, widget(c.widget));")) + control("person.js that draws every item", not problems(drawn(), False)) + finally: + with open(rj, "w") as f: + f.write(keep) + with open(js, "w") as f: + f.write(keep_js) + + # finding.js as it was: a missing state or one with no plugins read as a standalone adult. OLD_FINDING = ("if (!st || !st.plugins) { return Promise.resolve(UNKNOWN); }\n var fam = st.plugins.family;", "var fam = st && st.plugins && st.plugins.family;") diff --git a/tests/test_live_results.py b/tests/test_live_results.py index 8213596a2..7a403b68c 100644 --- a/tests/test_live_results.py +++ b/tests/test_live_results.py @@ -42,12 +42,17 @@ the ids and no other row does, the ranking's finding carries the secondary finding's id, and so does a segment of the lead that names the gene; the home page's view of the run (overview) lists no such finding among its - first ones and its closed lead names none. + first ones and its closed lead names none; a CHIP call, a copy-number dot + and a fact that name a closed result's gene carry its id (the CHIP chart's + panel of genes never does), results.json carries the closed results as + veil.spans, and core.js veilId(), which tags the family page's rows of a + child, answers each row as veil_id() does, under node. Each check runs again on a planted mistake and must fail there: 1 with VNTYPER's entry dropped, 2 with one card's caveat blanked, 3 with "ok" planted in a card and "All clear" in a script, 4 with the hits emptied, 5 with the results route registered without the login, 6 with a row printed as its Python dict, 7 with the Slivar adapter returning nothing, -8 with a veil rule that tags nothing (rows and lines) and one that tags every row. A check +8 with a veil rule that tags nothing (rows, lines, facts and drawings) and one that tags every row, and +veilId() with its gene rule or its overlap rule dropped. A check that cannot fail is no check. Run: python3 tests/test_live_results.py @@ -402,6 +407,75 @@ def veil_lines_problems(): return out +def widget_problems(): + """What is wrong with the veil's tags of a card's facts and drawing: a CHIP call and a copy-number dot, and a + fact, that name a closed result's gene carry its id; the CHIP chart's list of genes, the same panel for + everyone, is never tagged; nothing else is.""" + found = [{"id": "cpsr-tp53-test", "level": "act", "gene": "TP53", "detail": "TP53 c.524G>A"}, + {"id": "two-hits-smn1-test", "level": "act", "gene": "SMN1"}] + S = {"chip": {"state": "ok", "data": {"median_limit": 0.05, "median_depth": 72, "calls": [ + {"gene": "DNMT3A", "protein_change": "p.R882H", "vaf": 0.12, "alt_reads": 9, "depth": 75, "status": "pass", "note": "a clone"}, + {"gene": "TP53", "protein_change": "p.R175H", "vaf": 0.08, "alt_reads": 6, "depth": 75, "status": "pass", "note": "a clone"}], + "genes": [{"gene": "DNMT3A", "detection_limit_vaf": 0.05, "mean_depth": 75, "status": "ok"}, + {"gene": "TP53", "detection_limit_vaf": 0.06, "mean_depth": 70, "status": "ok"}]}}, + "paralogs": {"state": "ok", "data": {"genes": [ + {"gene": "SMN1", "copy_number": 1, "status": "called", "detail": "."}, + {"gene": "SMN2", "copy_number": 2, "status": "called", "detail": "."}]}}} + summ = {"sections": S} + chip = dict(live_results.a_chip(S, summ), id="chip") + par = dict(live_results.a_paralogs(S, summ), id="paralogs") + par["facts"] = [["SMN1 copies", "1"], ["Genes read", "2"]] + v = live_results.veil(summ, found, [{"sections": [chip, par]}]) + out = [] + want = {"chip": [None, "cpsr-tp53-test"], "paralogs": ["two-hits-smn1-test", None]} + if v.get("widget") != want: + out.append(f"drawings tagged {v.get('widget')}, want {want}") + if (v.get("facts") or {}) != {"paralogs": ["two-hits-smn1-test", None]}: + out.append(f"facts tagged {v.get('facts')}") + if v.get("spans") != [list(x) for x in live_results.closable(found)]: + out.append(f"spans {v.get('spans')}") + return out + + +# Rows as the family page tags them (family-page.js): [text, chrom, pos, end], against two closed results. +VEIL_SPANS = [["sf-kcnh2", "KCNH2", "chr7", 150960000, 150960000], ["sv-del", "KCNH2", "chr7", 150955000, 150965000], + ["two-hits-hla", "HLA-A", None, None, None]] +VEIL_ROWS = [["chr7:150960000 C>T", "chr7", "150960000", None], ["chr7:150961000 G>A", "chr7", "150961000", None], + ["chr7:150970000 G>A", "chr7", "150970000", None], ["chr3:48211904 C>T", "chr3", "48211904", None], + ["KCNH2 chr7:94371020:G>A chr7:94398114:T>C", None, None, None], + ["G0187 chr7:150,962,000:G>A chr7:1:T>C", None, None, None], ["G0412 chr2:1:A>G", None, None, None], + ["chr7:61600000-159285973", "chr7", "61600000", "159285973"], ["in KCNH2.", None, None, None], + ["HLA-A*02:01", None, None, None], ["HLA-AB", None, None, None], ["kcnh2 lower case", None, None, None], + ["7:150960000", "7", 150960000, None], ["chrX:1-2", "chrX", "x", None], ["", "chr7", "150955000", "150965000"]] +CORE_JS = os.path.join(ASSETS, "core.js") +NODE_VEIL = ("const vm=require('vm'),fs=require('fs');const w={};" + "const doc={documentElement:{setAttribute(){}},addEventListener(){}};" + "vm.runInNewContext(fs.readFileSync(process.argv[1],'utf8'),{window:w,document:doc});" + "const sp=JSON.parse(process.argv[2]);" + "console.log(JSON.stringify(JSON.parse(process.argv[3]).map(r=>w.PGPUI.veilId(sp,r[0],r[1],r[2],r[3]))))") + + +def node_veil(js_path): + """veilId() of core.js at JS_PATH on VEIL_ROWS, under node: [ids] or the error.""" + node = shutil.which("node") + if not node: + return "no node" + p = subprocess.run([node, "-e", NODE_VEIL, js_path, json.dumps(VEIL_SPANS), json.dumps(VEIL_ROWS)], + capture_output=True, text=True) + return json.loads(p.stdout) if p.returncode == 0 else p.stderr + + +def veil_js_problems(js_path): + """What veilId() of core.js answers differently from veil_id() on VEIL_ROWS.""" + want = [live_results.veil_id([tuple(x) for x in VEIL_SPANS], *r) for r in VEIL_ROWS] + got = node_veil(js_path) + if got != want: + return [f"veilId {got} != veil_id {want}"] + if len({w for w in want}) < 4: + return [f"the rows exercise too few answers: {want}"] + return [] + + def overview_problems(): """What is wrong with the home page's view of a child's run (overview): a finding that names a closed result (a symptom ranking of its gene) is listed among the first findings, or the closed lead still names it.""" @@ -519,6 +593,20 @@ def tagged(sample_dir): bad = overview_problems() check("8. a child's home tile lists no finding that names a closed result, and its closed lead names none", not bad, bad) + bad = widget_problems() + check("8. a CHIP call, a copy-number dot and a fact that name a closed result's gene carry its id, the CHIP " + "chart's gene panel none, and results.json carries the closed results as veil.spans", not bad, bad) + check("8. node is on the PATH (ubuntu-latest has it)", shutil.which("node") is not None) + bad = veil_js_problems(CORE_JS) + check("8. core.js veilId() tags each family-page row as veil_id() does (under node)", not bad, bad) + with open(CORE_JS) as f: + core = f.read() + for a, z, desc in (("if (s[1] && words[s[1]]) { return s[0]; }", "", "veilId() without its gene rule"), + ("l[1] <= s[4] && l[2] >= s[3]", "false", "veilId() without its overlap rule")): + planted = os.path.join(work, "core-planted.js") + with open(planted, "w") as f: + f.write(core.replace(a, z)) + control(desc, a in core and not veil_js_problems(planted)) bad = veil_problems(*tagged(d3)) check("8. the planted ClinVar hit's row, viewer link, tick and star bar carry its secondary finding's id, the " "deletion's span its own, every other card's row of the gene and the symptom ranking's finding one of them, " @@ -529,6 +617,7 @@ def tagged(sample_dir): live_results.veil_id = lambda spans, *a, **k: None control("a veil rule that tags nothing", not veil_problems(*tagged(d3))) control("a veil rule that tags no line", not veil_lines_problems()) + control("a veil rule that tags no fact or drawing", not widget_problems()) live_results.veil_id = lambda spans, *a, **k: spans[0][0] if spans else None control("a veil rule that tags every row", not veil_problems(*tagged(d3))) finally: From 76354c4481375a2f84625e34d4ab4957006596be Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:05:41 +0100 Subject: [PATCH 02/27] fix(live): tag the closed results for the family page, and close a child's dots and facts with them results.json carries the closed results as veil.spans, and tags each card's facts and the items of its drawing (CHIP calls, copy-number dots). core.js gets veilId(), a mirror of veil_id() for the family page's rows that a unit check runs against the Python rule under node, and the closed box both pages draw. Bead: pgp-iqon.24 --- assets/live/core.js | 55 ++++++++++++++++++++++++++++++++++++++++++- assets/live/person.js | 43 +++++++++++++++++++-------------- bin/live_results.py | 19 +++++++++++++-- 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/assets/live/core.js b/assets/live/core.js index b6e4ff071..e0aa05e32 100644 --- a/assets/live/core.js +++ b/assets/live/core.js @@ -208,8 +208,61 @@ return isFinite(x) && isFinite(y) && y > x ? (y - x) / 3600e3 : null; } + // --- the veil: a child's results to act on stay closed until someone opens them on purpose --------- + // The closed box, the same on a child's page (person.js) and on the family page (family-page.js): what stays + // closed, and the button that opens it. + function veil(text, button, onOpen) { + var v = el('div', { 'class': 'veil' }); + add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), text)); + var b = el('button', { type: 'button', 'class': 'btn' }, button || 'Show the result'); + b.addEventListener('click', onOpen); + add(v, b); + return v; + } + // The id of the closed result a row belongs to, or null: bin/live_results.py veil_id() word for word, for the + // rows only the family page holds (a child's de novo and compound-het candidates). spans: results.json's + // veil.spans, [[id, gene, chrom, start, end]]. text: what the row shows (its gene names; its loci are read + // too); chrom, pos and end its own position. tests/test_live_results.py runs it under node against veil_id(). + var LOCUS_RE = /(?= s[3]; })) { return s[0]; } + } + return 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, 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 }; + hm: hm, dm: dm, hours: hours, veil: veil, veilId: veilId }; }()); diff --git a/assets/live/person.js b/assets/live/person.js index 6dcd7490f..c0b582ac8 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -344,6 +344,22 @@ out.rows = rows; return [rows.length ? out : null, t.rows.length - rows.length]; } + // A card's facts and drawing without their closed items (a fact, a CHIP call or a copy-number dot that names a + // closed result's gene): [facts, widget, how many items they left out]. + function openFacts(c) { + var ft = veilTags('facts', c.id), facts = (c.facts || []).filter(function (f, i) { return !(ft[i] && tagClosed(ft[i])); }); + var w = c.widget, key = w && (w.kind === 'vaf' ? 'calls' : w.kind === 'copies' ? 'genes' : null); + var wt = veilTags('widget', c.id), shut = (c.facts || []).length - facts.length; + if (key && wt.length && w[key]) { + var items = w[key].filter(function (x, i) { return !(wt[i] && tagClosed(wt[i])); }); + shut += w[key].length - items.length; + var copy = {}; + Object.keys(w).forEach(function (k) { copy[k] = w[k]; }); + copy[key] = items; + w = copy; + } + return [facts, w, shut]; + } function veiledOf(c) { return (c.findings || []).map(findingById).filter(function (f) { return f && veiled(f); }); } // The search box (plugins/search.js) asks the same question for each hit, by the summary key and the anchor // of the section it comes from: a hit from a card this page keeps closed is left out. A closed page with @@ -398,20 +414,16 @@ } else if (g === 'empty') { add(text, 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. '), - (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 () { openFinding(closedFs[0]); 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(text, v); + add(text, U.veil((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.', null, + function () { openFinding(closedFs[0]); redraw(true); var x = document.getElementById('f-' + closedFs[0].id) || document.getElementById('card-' + c.id); if (x) { x.scrollIntoView({ block: 'start' }); } })); } else { (c.notes || []).forEach(function (n) { add(text, el('p', { 'class': 'note-stale' }, n)); }); if (c.headline) { add(text, el('p', { 'class': 'headline' }, c.headline)); } - add(margin, U.facts(c.facts)); - add(text, widget(c.widget)); + var of = openFacts(c); + add(margin, U.facts(of[0])); + add(text, widget(of[1])); Object.keys((results && results.charts) || {}).forEach(function (chart) { if (results.charts[chart] === c.id) { add(text, el('div', { 'class': 'chart-slot', 'data-chart-slot': chart })); } }); @@ -419,7 +431,7 @@ var ot = openTable(c), tb = U.table(ot[0]); var lt = veilTags('lines', c.id); var lines = (c.lines || []).filter(function (l, i) { return !(lt[i] && tagClosed(lt[i])); }); - var shut = ot[1] + (c.lines || []).length - lines.length; + var shut = ot[1] + (c.lines || []).length - lines.length + of[2]; if (tb) { add(text, tb); } else if (c.empty_text && !ot[1]) { add(text, el('p', { 'class': 'empty' }, c.empty_text)); } if (shut) { add(text, el('p', { 'class': 'dim veil-rows' }, (shut === 1 ? 'One row stays' : shut + ' rows stay') + @@ -534,13 +546,8 @@ if (at) { art.classList.add('new'); add(art, el('span', { 'class': 'newband' }, 'New at ' + U.hm(at))); add(art, el('br', {})); } 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. '), - '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 () { openFinding(f); redraw(true); var x = document.getElementById('f-' + f.id); if (x) { x.scrollIntoView({ block: 'start' }); } }); - add(v, b); - add(art, v); + add(art, U.veil('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.', + null, function () { openFinding(f); redraw(true); var x = document.getElementById('f-' + f.id); if (x) { x.scrollIntoView({ block: 'start' }); } })); add(row, art, el('aside', {})); return row; } diff --git a/bin/live_results.py b/bin/live_results.py index ef5d93eb3..a2202011f 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -1210,11 +1210,16 @@ def veil(summary, found, chapters, lead_parts=()): intronic site is printed whether hit or not), the marks of the report's charts (marks: chart -> {mark id: id}), every other finding that names a closed result (findings: finding id -> id; a symptom ranking can rank the gene) and the lead's segments that name one (lead: one per segment of LEAD_PARTS, the "act" - names left out: the page closes those itself). Empty when nothing can be closed.""" + names left out: the page closes those itself), each card's facts (facts: card -> one per fact) and the items + of its drawing (widget: card -> one per CHIP call or copy-number dot). spans is closable() itself, so the + family page (assets/live/family-page.js) can tag its own rows of a child, the de novo and compound-het + candidates, by this rule (core.js veilId(), checked against veil_id() under node). Empty when nothing can + be closed.""" spans = closable(found) if not spans: return {} - out = {"rows": {}, "viewer": {}, "lines": {}, "marks": {}, "findings": {}, "lead": []} + out = {"rows": {}, "viewer": {}, "lines": {}, "facts": {}, "widget": {}, "marks": {}, "findings": {}, "lead": [], + "spans": [list(s) for s in spans]} S = summary.get("sections") or {} hits = [h for h in _items(_data(S, "clinvar").get("hits"))[:50]] tags = [veil_id(spans, str(h.get("gene") or ""), h.get("chrom"), h.get("pos")) if isinstance(h, dict) else None @@ -1238,6 +1243,16 @@ def veil(summary, found, chapters, lead_parts=()): tags = [veil_id(spans, ln) for ln in c.get("lines") or []] if any(tags): out["lines"][c["id"]] = tags + tags = [veil_id(spans, " ".join(str(x) for x in f)) for f in c.get("facts") or []] + if any(tags): + out["facts"][c["id"]] = tags + # A drawing's items: the CHIP calls (gene and change) and the copy-number dots (gene). The CHIP + # chart's list of genes with their detection limits is the same panel for everyone: never tagged. + w = c.get("widget") or {} + items = w.get("calls") if w.get("kind") == "vaf" else w.get("genes") if w.get("kind") == "copies" else None + tags = [veil_id(spans, f"{x.get('gene') or ''} {x.get('change') or ''}") for x in items or []] + if any(tags): + out["widget"][c["id"]] = tags try: specs = rr.chart_specs(summary) except (KeyError, TypeError, ValueError, AttributeError, IndexError, ZeroDivisionError): From 1bf0623a0a529e1b19842d8ab2c98c22e1c1e46b Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:05:41 +0100 Subject: [PATCH 03/27] fix(live): keep a child's closed result off the family page The family page reads each child's results.json and keeps closed every de novo candidate, compound-het pair and grandparent stretch that names a closed result, until it is opened there; while it cannot read them, every such row of that child stays closed. Bead: pgp-iqon.24 --- CHANGELOG.md | 2 + assets/live/family-page.js | 131 ++++++++++++++++++++++++++++++++++--- assets/live/live.css | 1 + docs/live-site.md | 6 +- tests/test_live_demo.py | 2 +- 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fcb5480..1ff9e66f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Each release's notes. From 1.0.0 on, a major or minor release has hand-written n ## Unreleased +- The family page now keeps a child's results to act on closed, as the child's own page does. A de novo candidate or a compound-het pair that names a closed result, and a stretch painted from a grandparent over one, stay behind "Show the result" until that result is opened there; a reload closes them again. While the page cannot read the child's results, every such row of that child stays closed. On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: the files behind the pages hold every row. + - VEP consequence ranking (steps 23 and 31) and the gnomAD constraint join now run in the Rust `og` binary, inside `BCFTOOLS_IMAGE`: `og vcf csq-rank` replaces `bin/csq_rank.awk` and `og table constraint-join` replaces `bin/constraint_join.awk`, which are gone. Neither awk program runs on the host anymore (step 23 still uses the host's `awk` for small table edits), and the ranker is no longer the slow stage of step 23's main pipe. Steps 23 and 31 now need `og`: `scripts/setup.sh --og` builds it. Their output is the same, byte for byte, except for these changes. - The seven `##INFO=` header descriptions end in `(og vcf csq-rank)`, not `(bin/csq_rank.awk)`. No tag, value or column changes. - A ranking floor that is not a number, or a `--keep` other than `all` or `noncommon`, is a usage error (exit 2). awk read such a floor as 0. diff --git a/assets/live/family-page.js b/assets/live/family-page.js index ed5421947..2cbf03b6a 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -6,6 +6,9 @@ // 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. +// A child's results to act on stay closed here as on the child's own page (person.js): each child's results.json +// gives the closed results (veil.spans), and a row of the child that names one (a de novo or compound-het +// candidate, a stretch painted from a grandparent) stays closed until that result is opened on purpose here. (function () { 'use strict'; var U = window.PGPUI; @@ -70,6 +73,73 @@ } function memberUrl(f, s) { return f.runs && f.runs[s] ? f.runs[s].url : null; } + // --- the veil --------------------------------------------------------------------------------------- + // What this page knows of each child's results: { status: 'pending', 'known' or 'failed', spans, acts }. A child + // with no run here, or whose results.json has not answered or could not be read, cannot say which rows name a + // result to act on, so every row of theirs that could stays closed (UNKNOWN) until opened on purpose. + // Opened results are kept for the life of this page, as on the child's page: a reload closes them again. + var UNKNOWN = '?'; + var kids = {}, opened = {}, openedAll = {}, last = null; + function loadKids(f) { + (f.state.children || []).forEach(function (c) { + var url = memberUrl(f, c.child); + var k = kids[c.child]; + if (!url) { kids[c.child] = { status: 'failed' }; return; } + if (k && k.url === url && k.busy) { return; } + kids[c.child] = k && k.url === url ? k : { status: 'pending', url: url }; + kids[c.child].busy = true; + U.getJSON(url + 'results.json').then(function (r) { + var cur = kids[c.child]; + if (!cur || cur.url !== url) { return; } + cur.busy = false; + var v = (r && r.veil) || {}; + var acts = ((r && r.findings) || []).filter(function (x) { return x.level === 'act'; }).map(function (x) { return x.id; }); + var next = r ? { status: 'known', url: url, spans: v.spans || null, acts: acts } : { status: 'failed', url: url }; + // An answer that cannot be read keeps what was known: the page never opens on a failed request. + if (!r && cur.status === 'known') { return; } + var key = JSON.stringify([next.status, next.spans, next.acts]); + if (key === cur.key) { return; } + next.key = key; + kids[c.child] = next; + if (last) { redraw(); } + }).catch(function () { + var cur = kids[c.child]; + if (!cur || cur.url !== url) { return; } + cur.busy = false; + if (cur.status !== 'known' && cur.status !== 'failed') { cur.status = 'failed'; cur.key = 'failed'; if (last) { redraw(); } } + }); + }); + } + // The closed result a row of CHILD belongs to: its id, UNKNOWN when this page cannot tell, or null. + function tagOf(child, text, chrom, pos, end) { + var k = kids[child]; + if (!k || k.status !== 'known') { return UNKNOWN; } + if (!k.spans) { return k.acts.length ? UNKNOWN : null; } // results without the tags: cannot tell + return U.veilId(k.spans, text, chrom, pos, end); + } + function isClosed(child, tag) { return !!tag && !(tag === UNKNOWN ? openedAll[child] : opened[tag]); } + // The rows of CHILD split by the veil: { open: rows, closed: [tags of the closed rows] }. + function veiled(child, rows, tagFn) { + var out = { open: [], closed: [] }; + rows.forEach(function (r) { + var t = tagFn(r); + if (isClosed(child, t)) { out.closed.push(t); } else { out.open.push(r); } + }); + return out; + } + // The closed box under a table of CHILD: how many rows stay closed, and one button that opens one result. + function veilBox(child, closed, what, whats) { + if (!closed.length) { return null; } + var n = closed.length === 1 ? 'One ' + what + ' stays' : closed.length + ' ' + whats + ' stay'; + var first = closed[0]; + if (first === UNKNOWN) { + return U.veil(n + ' closed: this page cannot read ' + child + '’s results yet, so it cannot tell which ' + whats + + ' name a result to act on in adult life.', 'Show the ' + whats, function () { openedAll[child] = true; redraw(); }); + } + return U.veil(n + ' closed with the result to act on in adult life ' + (closed.length === 1 ? 'it belongs' : 'they belong') + + ' to, until that result is opened.', null, function () { opened[first] = true; redraw(); }); + } + // The pedigree drawn from the layout the server gives: a sequenced person with a run here is a link. function pedigree(f) { var L = f.layout, st = f.state; @@ -286,7 +356,13 @@ // 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) { + function painting(f, child, all) { + // A stretch drawn from a grandparent says which grandparent's copy the child got there, so one over a + // closed result says whether the child got it: it stays closed. A stretch from a parent alone says nothing. + var split = veiled(child, all, function (r) { + return r.ancestor && r.ancestor !== '.' ? tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end) : null; + }); + var rows = split.open; var lengths = f.chrom_lengths || []; var longest = Math.max.apply(null, lengths.map(function (x) { return x[1]; }).concat([1])); var colours = Object.create(null), next = 1; @@ -305,6 +381,14 @@ ['paternal', 'maternal'].forEach(function (side) { var track = el('span', { 'class': 'paint-track ' + side }); track.style.setProperty('--len', (100 * cl[1] / longest).toFixed(2) + '%'); + all.filter(function (r) { return r.chrom === chrom && r.side === side && rows.indexOf(r) < 0; }).forEach(function (r) { + var a = parseInt(r.start, 10), b = parseInt(r.end, 10); + if (!isFinite(a) || !isFinite(b) || b < a) { return; } + var seg = el('span', { 'class': 'paint-seg closed', title: 'A closed stretch' }); + 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); + }); 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; } @@ -329,8 +413,10 @@ 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(leg, el('span', { 'class': 'dim' }, ' Grey: no stretch called (no informative site, a gap of the reference).' + + (split.closed.length ? ' Hatched: a closed stretch.' : ''))); add(fig, leg); + add(fig, veilBox(child, split.closed, 'stretch', 'stretches')); 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'], @@ -361,27 +447,41 @@ 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' })); + // The drawing as an image cannot close one stretch: while this child has a result to act on that is + // closed (or this page cannot tell), it stays closed whole. + var k = kids[c.child] || {}; + var shut = k.status !== 'known' ? [UNKNOWN] : (k.spans || []).map(function (s) { return s[0]; }).filter(function (id) { return !opened[id]; }); + if (!k.spans && k.status === 'known' && k.acts.length) { shut = [UNKNOWN]; } + if (shut.length && !(shut[0] === UNKNOWN && openedAll[c.child])) { + add(d, U.veil('The chromosome map of ' + c.child + ' stays closed while a result to act on in adult life is: ' + + 'it could show which copy the child got there.', 'Show the map', function () { shut.forEach(function (t) { if (t === UNKNOWN) { openedAll[c.child] = true; } else { opened[t] = true; } }); redraw(); })); + } else { + 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) { + var dv = veiled(c.child, dn, function (r) { return tagOf(c.child, r.chrom + ':' + r.pos + ' ' + r.ref + '>' + r.alt, r.chrom, r.pos); }); 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, + rows: dv.open.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)); + add(d, veilBox(c.child, dv.closed, 'row', 'rows')); } else { add(d, el('p', { 'class': 'dim', 'data-trio': 'de_novo' }, empty(c.child, 'de_novo', 'de_novo.tsv'))); } var ch = (f.compound_het || []).filter(function (r) { return r.child === c.child; }); add(d, el('h3', {}, 'Compound-het pairs')); if (ch.length) { + var cv = veiled(c.child, ch, function (r) { return tagOf(c.child, [r.gene, r.variant_a, r.variant_b].join(' ')); }); 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)); + rows: cv.open.map(function (r) { return [r.gene, r.phase, r.variant_a, r.a_from, r.variant_b, r.b_from]; }) }, 8)); + add(d, veilBox(c.child, cv.closed, 'row', 'rows')); } else { add(d, el('p', { 'class': 'dim', 'data-trio': 'compound_het' }, empty(c.child, 'compound_het', 'compound_het.tsv'))); } @@ -431,7 +531,12 @@ } function draw(f) { + last = f; var main = document.getElementById('family'); + // A redraw (a result opened, a child's results answered) keeps each child's fold as the reader left it. + var folds = {}; + Array.prototype.forEach.call(main.querySelectorAll('details[data-child]'), function (x) { folds[x.getAttribute('data-child')] = x.open; }); + var y = window.scrollY; main.textContent = ''; var page = el('section', { 'class': 'family-page', 'data-plugin': 'family', 'aria-label': 'Family ' + f.state.name }); if (f.state.error) { @@ -462,14 +567,22 @@ 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); + Array.prototype.forEach.call(main.querySelectorAll('details[data-child]'), function (x) { + var k = x.getAttribute('data-child'); + if (k in folds) { x.open = folds[k]; } + }); + window.scrollTo(0, y); + // What the page knows of each child's results (for the tests): "DEMO-003:known". + root.setAttribute('data-pgp-kids', Object.keys(kids).sort().map(function (c) { return c + ':' + kids[c].status; }).join(' ')); root.setAttribute('data-pgp-status', 'ready'); } + function redraw() { if (last) { draw(last); } } // The page asks again every 30 s and redraws only when family.json changed (a member's run moved on, // step 39 finished). A failed first request says so, with a retry; a later one keeps what is shown. - var shown = null; + var drawnKey = null; function failed() { - if (shown !== null) { return; } + if (drawnKey !== null) { return; } var main = document.getElementById('family'); main.textContent = ''; var b = el('button', { type: 'button', 'class': 'more' }, 'Try again'); @@ -481,7 +594,9 @@ return U.getJSON('family.json').then(function (f) { if (!f) { failed(); return; } var k = JSON.stringify(f); - if (k !== shown) { shown = k; draw(f); } + // Each child's results are asked again with the family: a result to act on can appear while a run goes. + if (!f.state.error) { loadKids(f); } + if (k !== drawnKey) { drawnKey = k; draw(f); } }).catch(failed); } function start() { diff --git a/assets/live/live.css b/assets/live/live.css index 5dba9f176..4adff6602 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -607,6 +607,7 @@ ul.rel-pairs .chip { white-space: normal; } .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-seg.closed { background: repeating-linear-gradient(-45deg, rgba(143, 141, 134, .45) 0 2px, transparent 2px 6px); } .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; } diff --git a/docs/live-site.md b/docs/live-site.md index 11b784bc2..0f6935941 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 its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. +A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows, lines and facts, a copy-number dot (a CHIP call is a finding of its own card, so the CHIP card closes whole), a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. A stopped run's page says so under the name and in the summary line, with the day it was last heard from, and warns that the report may be incomplete; a section still waiting for its step says the run stopped before that step ended. @@ -72,7 +72,7 @@ The header search finds a gene or rsID across the results and opens the card tha **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//`). 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, or why the child was not assessed. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one, and a stretch painted from a grandparent over one (it says which grandparent's copy the child got there), stay closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. Each click opens one result, and a reload closes it again. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, or a request that failed), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. On a phone the grid becomes a list of pairs. 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); under 700 px the report is short: the findings of sections 1 to 4 (to act on, medicines, family, worth a look) stay open, while the cards behind them, every information card and the bodies of sections 5 to 10 start closed, each behind one line that says what it holds (section 5 stays open when it holds a finding about data quality, or a card the report flags with no finding of its own). On a child's page, and while the family's roles are not known, the findings to act on stay behind "Show the result" on a phone too. A fold you open stays open when the page is drawn again, the phone's folds too when you turn the phone to a wide screen and back. The counts read one line per level, and the "How to read this" note and the links between findings are left out. A link to a card, a section or a finding, a search hit and a tab open the folds around it (a Ctrl, Cmd or Shift click leaves this page as it is); 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. `tests/demo/render_pictures.py --out DIR --only live-phone` draws the picture below from the demo. @@ -88,7 +88,7 @@ The reads cross the network to the browser that logged in: only the byte ranges `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; a finding that names a result to act on, such as a symptom ranking of its gene, is never among them), `lead` (its lead paragraph as plain text), `lead_closed` (the same with the names of the findings to act on left out, and every other part that names one, 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. +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`, `databases` and `veil` (on a child's page, the rows outside a result's own card that close with it, and `spans`, the closed results with their gene and position, which the family page tags a child's rows with by the same rule). 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_demo.py b/tests/test_live_demo.py index 8f10fe575..54431709f 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -408,7 +408,7 @@ def widget_checks(b, base, out): keep = f.read() try: with open(js, "w") as f: - f.write(keep.replace("add(text, widget(c.widget));", "")) + f.write(keep.replace("add(text, widget(of[1]));", "")) control("person.js without its widget line", not widget_problems(widget_counts(b, base))) finally: with open(js, "w") as f: From 0d715a074a53d84149d9ea6b3627610ef7399afc Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:42:09 +0100 Subject: [PATCH 04/27] fix(live): keep a family row closed until every closed result it names is opened A compound-het pair or a painted stretch can name two closed results: core.js veilIds() lists each one by veil_id()'s rule, and the row stays closed until all are opened. Opened results are kept per child and run, since two children can carry the same result id. Results the server could not build count as unreadable. On a child's page the phone's card line takes only an open fact, and the closed count names rows and items apart. Bead: pgp-iqon.24 --- assets/live/core.js | 7 +++++- assets/live/family-page.js | 45 ++++++++++++++++++++++---------------- assets/live/person.js | 14 +++++++----- docs/live-site.md | 2 +- tests/test_live_demo.py | 33 ++++++++++++++++++++++------ tests/test_live_results.py | 24 ++++++++++++-------- 6 files changed, 83 insertions(+), 42 deletions(-) diff --git a/assets/live/core.js b/assets/live/core.js index e0aa05e32..fff06e955 100644 --- a/assets/live/core.js +++ b/assets/live/core.js @@ -260,9 +260,14 @@ } return null; } + // Every closed result a row belongs to, by the same rule taken one result at a time: a compound-het pair can + // name two, and a painted stretch can hold two, so the family page keeps such a row closed until each is opened. + function veilIds(spans, text, chrom, pos, end) { + return (spans || []).filter(function (s) { return veilId([s], text, chrom, pos, end); }).map(function (s) { return s[0]; }); + } 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, 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, veil: veil, veilId: veilId }; + hm: hm, dm: dm, hours: hours, veil: veil, veilId: veilId, veilIds: veilIds }; }()); diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 2cbf03b6a..1253b4d59 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -77,9 +77,11 @@ // What this page knows of each child's results: { status: 'pending', 'known' or 'failed', spans, acts }. A child // with no run here, or whose results.json has not answered or could not be read, cannot say which rows name a // result to act on, so every row of theirs that could stays closed (UNKNOWN) until opened on purpose. - // Opened results are kept for the life of this page, as on the child's page: a reload closes them again. + // Opened results are kept for the life of this page, as on the child's page: a reload closes them again. They + // are kept per child and per run (two children can carry the same result, under the same id). var UNKNOWN = '?'; var kids = {}, opened = {}, openedAll = {}, last = null; + function openKey(child, id) { return (kids[child] && kids[child].url) + '|' + child + '|' + id; } function loadKids(f) { (f.state.children || []).forEach(function (c) { var url = memberUrl(f, c.child); @@ -93,10 +95,12 @@ if (!cur || cur.url !== url) { return; } cur.busy = false; var v = (r && r.veil) || {}; - var acts = ((r && r.findings) || []).filter(function (x) { return x.level === 'act'; }).map(function (x) { return x.id; }); - var next = r ? { status: 'known', url: url, spans: v.spans || null, acts: acts } : { status: 'failed', url: url }; + // Results the server could not build come back without their findings: as unreadable as no answer. + var usable = !!(r && Array.isArray(r.findings)); + var acts = usable ? r.findings.filter(function (x) { return x.level === 'act'; }).map(function (x) { return x.id; }) : []; + var next = usable ? { status: 'known', url: url, spans: v.spans || null, acts: acts } : { status: 'failed', url: url }; // An answer that cannot be read keeps what was known: the page never opens on a failed request. - if (!r && cur.status === 'known') { return; } + if (!usable && cur.status === 'known') { return; } var key = JSON.stringify([next.status, next.spans, next.acts]); if (key === cur.key) { return; } next.key = key; @@ -110,20 +114,23 @@ }); }); } - // The closed result a row of CHILD belongs to: its id, UNKNOWN when this page cannot tell, or null. + // The closed results a row of CHILD belongs to: their ids, [UNKNOWN] when this page cannot tell, or []. function tagOf(child, text, chrom, pos, end) { var k = kids[child]; - if (!k || k.status !== 'known') { return UNKNOWN; } - if (!k.spans) { return k.acts.length ? UNKNOWN : null; } // results without the tags: cannot tell - return U.veilId(k.spans, text, chrom, pos, end); + if (!k || k.status !== 'known') { return [UNKNOWN]; } + if (!k.spans) { return k.acts.length ? [UNKNOWN] : []; } // results without the tags: cannot tell + return U.veilIds(k.spans, text, chrom, pos, end); } - function isClosed(child, tag) { return !!tag && !(tag === UNKNOWN ? openedAll[child] : opened[tag]); } - // The rows of CHILD split by the veil: { open: rows, closed: [tags of the closed rows] }. + // The results of TAGS still closed for CHILD: a row stays closed while one of them is. + function stillClosed(child, tags) { + return tags.filter(function (t) { return t === UNKNOWN ? !openedAll[child] : !opened[openKey(child, t)]; }); + } + // The rows of CHILD split by the veil: { open: rows, closed: [the still closed results of each closed row] }. function veiled(child, rows, tagFn) { var out = { open: [], closed: [] }; rows.forEach(function (r) { - var t = tagFn(r); - if (isClosed(child, t)) { out.closed.push(t); } else { out.open.push(r); } + var t = stillClosed(child, tagFn(r)); + if (t.length) { out.closed.push(t); } else { out.open.push(r); } }); return out; } @@ -131,13 +138,13 @@ function veilBox(child, closed, what, whats) { if (!closed.length) { return null; } var n = closed.length === 1 ? 'One ' + what + ' stays' : closed.length + ' ' + whats + ' stay'; - var first = closed[0]; + var first = closed[0][0]; if (first === UNKNOWN) { return U.veil(n + ' closed: this page cannot read ' + child + '’s results yet, so it cannot tell which ' + whats + ' name a result to act on in adult life.', 'Show the ' + whats, function () { openedAll[child] = true; redraw(); }); } return U.veil(n + ' closed with the result to act on in adult life ' + (closed.length === 1 ? 'it belongs' : 'they belong') + - ' to, until that result is opened.', null, function () { opened[first] = true; redraw(); }); + ' to, until that result is opened.', null, function () { opened[openKey(child, first)] = true; redraw(); }); } // The pedigree drawn from the layout the server gives: a sequenced person with a run here is a link. @@ -360,7 +367,7 @@ // A stretch drawn from a grandparent says which grandparent's copy the child got there, so one over a // closed result says whether the child got it: it stays closed. A stretch from a parent alone says nothing. var split = veiled(child, all, function (r) { - return r.ancestor && r.ancestor !== '.' ? tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end) : null; + return r.ancestor && r.ancestor !== '.' ? tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end) : []; }); var rows = split.open; var lengths = f.chrom_lengths || []; @@ -450,11 +457,11 @@ // The drawing as an image cannot close one stretch: while this child has a result to act on that is // closed (or this page cannot tell), it stays closed whole. var k = kids[c.child] || {}; - var shut = k.status !== 'known' ? [UNKNOWN] : (k.spans || []).map(function (s) { return s[0]; }).filter(function (id) { return !opened[id]; }); - if (!k.spans && k.status === 'known' && k.acts.length) { shut = [UNKNOWN]; } - if (shut.length && !(shut[0] === UNKNOWN && openedAll[c.child])) { + var all = k.status !== 'known' || (!k.spans && k.acts.length) ? [UNKNOWN] : (k.spans || []).map(function (s) { return s[0]; }); + var shut = stillClosed(c.child, all); + if (shut.length) { add(d, U.veil('The chromosome map of ' + c.child + ' stays closed while a result to act on in adult life is: ' + - 'it could show which copy the child got there.', 'Show the map', function () { shut.forEach(function (t) { if (t === UNKNOWN) { openedAll[c.child] = true; } else { opened[t] = true; } }); redraw(); })); + 'it could show which copy the child got there.', 'Show the map', function () { shut.forEach(function (t) { if (t === UNKNOWN) { openedAll[c.child] = true; } else { opened[openKey(c.child, t)] = true; } }); redraw(); })); } else { add(d, el('img', { src: c.segments, alt: 'Chromosome map of ' + c.child, 'class': 'segments' })); } diff --git a/assets/live/person.js b/assets/live/person.js index c0b582ac8..4a45383d6 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -431,11 +431,15 @@ var ot = openTable(c), tb = U.table(ot[0]); var lt = veilTags('lines', c.id); var lines = (c.lines || []).filter(function (l, i) { return !(lt[i] && tagClosed(lt[i])); }); - var shut = ot[1] + (c.lines || []).length - lines.length + of[2]; + var shut = ot[1] + (c.lines || []).length - lines.length, items = of[2]; if (tb) { add(text, tb); } else if (c.empty_text && !ot[1]) { add(text, el('p', { 'class': 'empty' }, c.empty_text)); } - if (shut) { - add(text, el('p', { 'class': 'dim veil-rows' }, (shut === 1 ? 'One row stays' : shut + ' rows stay') + - ' closed with the result to act on ' + (shut === 1 ? 'it belongs' : 'they belong') + ' to, until that result is opened.')); + if (shut || items) { + // Rows of its table and lines, and items of its facts and drawing, counted apart. + var what = [shut ? (shut === 1 ? 'One row' : shut + ' rows') : '', items ? (items === 1 ? (shut ? 'one item' : 'One item') : items + ' items') + + ' of its numbers or drawing' : ''].filter(Boolean).join(' and '); + var many = shut + items > 1; + add(text, el('p', { 'class': 'dim veil-rows' }, what + (many ? ' stay' : ' stays') + + ' closed with the result to act on ' + (many ? 'they belong' : 'it belongs') + ' to, until that result is opened.')); } add(text, lineList(lines)); if (c.hidden) { @@ -711,7 +715,7 @@ function cardLine(c) { var g = gate(c); return 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)) + : g === 'show' ? (c.headline || (openFacts(c)[0][0] ? openFacts(c)[0][0][0] + ': ' + openFacts(c)[0][0][1] : NOT_ALL_CLEAR)) : pending(g) ? 'Waiting for ' + stepWords(c) + '.' : g === 'failed' ? 'Not assessed: its step failed.' : 'Not assessed in this run.'; } // A card the report flags that has no finding of its own: on a phone it is the only place that flag diff --git a/docs/live-site.md b/docs/live-site.md index 0f6935941..4634555ab 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -72,7 +72,7 @@ The header search finds a gene or rsID across the results and opens the card tha **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//`). 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one, and a stretch painted from a grandparent over one (it says which grandparent's copy the child got there), stay closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. Each click opens one result, and a reload closes it again. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, or a request that failed), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one, and a stretch painted from a grandparent over one (it says which grandparent's copy the child got there), stay closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. Each click opens one result, a row that names two closed results stays closed until both are opened, and a reload closes them again. Opening a result opens it for that child only. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, a request that failed, or results the server could not build), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. On a phone the grid becomes a list of pairs. 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); under 700 px the report is short: the findings of sections 1 to 4 (to act on, medicines, family, worth a look) stay open, while the cards behind them, every information card and the bodies of sections 5 to 10 start closed, each behind one line that says what it holds (section 5 stays open when it holds a finding about data quality, or a card the report flags with no finding of its own). On a child's page, and while the family's roles are not known, the findings to act on stay behind "Show the result" on a phone too. A fold you open stays open when the page is drawn again, the phone's folds too when you turn the phone to a wide screen and back. The counts read one line per level, and the "How to read this" note and the links between findings are left out. A link to a card, a section or a finding, a search hit and a tab open the folds around it (a Ctrl, Cmd or Shift click leaves this page as it is); 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. `tests/demo/render_pictures.py --out DIR --only live-phone` draws the picture below from the demo. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 54431709f..2fa60be1a 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -57,8 +57,9 @@ result (the compound-het pair in KCNH2 of the fixture, and a de novo candidate and a stretch painted from a grandparent planted in family.json) name its gene, positions and that grandparent in no row, link, title or - count until "Show the result" is clicked there, the click brings each row - back, and a reload closes them again; with the son's results.json + count until "Show the result" is clicked there, a row that names both + planted results stays closed until both are opened (one per click), each + row then comes back, and a reload closes them again; with the son's results.json unavailable every row of his that could name one stays closed; on his page a CHIP call and a copy-number dot in the gene of a closed result stay out of their drawings until it is opened; DEMO-001's page @@ -771,19 +772,28 @@ def family_closed(b, base): def family_problems(b, base): - """[problems] of the family page: closed, then opened with one click, then reloaded.""" + """[problems] of the family page: closed, then opened one result per click, then reloaded. Each planted row + names both of the son's planted results (the secondary finding's position, and the deletion's span around it, + both in KCNH2), so the first click opens one result and every row stays closed for the other.""" gene, hit, _ = planted_words() out = family_closed(b, base) if out: return out - b.eval("document.querySelector('details[data-child=\"DEMO-003\"] .veil button').click(); true") + click = "document.querySelector('details[data-child=\"DEMO-003\"] .veil button').click(); true" + b.eval(click) + time.sleep(0.5) + shown = b.eval(FAMILY_SHOWN_JS) or "" + left = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") + if any(w in shown for w in [gene, GRANDPARENT, PAIR_B] + hit) or left != 3: + out.append(f"one click opened a row that also names the other closed result ({left} closed boxes left)") + b.eval(click) time.sleep(0.5) shown = b.eval(FAMILY_SHOWN_JS) or "" rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") left = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") if not all(w in shown for w in [gene, GRANDPARENT, hit[0]]) or json.loads(rows or "[0,0,0]")[1:] != [3, 3] or left: - out.append(f"one click on 'Show the result' did not bring back each row: tables {rows}, {left} closed boxes left") + out.append(f"two clicks on 'Show the result' did not bring back each row: tables {rows}, {left} closed boxes left") out += [f"after a reload: {x}" for x in family_closed(b, base)] return out @@ -835,9 +845,10 @@ def write(p, text): bad = family_problems(b, base) check("browser: on the family page the son's rows that name his planted result (a compound-het pair, a de novo " "candidate, a stretch from a grandparent) name its gene, positions and grandparent nowhere until 'Show the " - "result' is clicked there, one click brings each row back, and a reload closes them", not bad, bad) + "result' is clicked there, a row naming two closed results stays closed until both are opened, then each " + "row comes back, and a reload closes them", not bad, bad) for p, a, z, desc in ( - (fp, "function isClosed(child, tag) { return !!tag && ", "function isClosed(child, tag) { return false && ", + (fp, "return tags.filter(function (t) {", "return [].filter(function (t) {", "family-page.js whose rows ignore the veil"), (cj, " if (!spans || !spans.length) { return null; }", " return null;", "core.js whose veilId() tags nothing")): if a not in keep[p]: @@ -853,6 +864,14 @@ def write(p, text): os.replace(rj + ".away", rj) check("browser: with the son's results.json unavailable the family page keeps every row of his that could name " "a result to act on closed", not bad, bad) + # The results route answers a summary it cannot build with a note and no findings (owngenome_live_results.py). + with open(rj) as f: + keep[rj] = f.read() + write(rj, json.dumps({"schema_version": 1, "sample": OTHERS[1], "chapters": [], + "note": "The results could not be read (KeyError)."})) + bad = family_unknown(b, base) + check("browser: with the son's results answered as unreadable (a note, no findings) the family page keeps every " + "row of his that could name a result to act on closed", not bad, bad) finally: keep.pop("planted", None) for p, text in keep.items(): diff --git a/tests/test_live_results.py b/tests/test_live_results.py index 7a403b68c..becc5e182 100644 --- a/tests/test_live_results.py +++ b/tests/test_live_results.py @@ -52,7 +52,7 @@ and "All clear" in a script, 4 with the hits emptied, 5 with the results route registered without the login, 6 with a row printed as its Python dict, 7 with the Slivar adapter returning nothing, 8 with a veil rule that tags nothing (rows, lines, facts and drawings) and one that tags every row, and -veilId() with its gene rule or its overlap rule dropped. A check +veilId() with its gene rule or its overlap rule dropped and veilIds() keeping only the first result. A check that cannot fail is no check. Run: python3 tests/test_live_results.py @@ -452,11 +452,12 @@ def widget_problems(): "const doc={documentElement:{setAttribute(){}},addEventListener(){}};" "vm.runInNewContext(fs.readFileSync(process.argv[1],'utf8'),{window:w,document:doc});" "const sp=JSON.parse(process.argv[2]);" - "console.log(JSON.stringify(JSON.parse(process.argv[3]).map(r=>w.PGPUI.veilId(sp,r[0],r[1],r[2],r[3]))))") + "console.log(JSON.stringify(JSON.parse(process.argv[3]).map(r=>[w.PGPUI.veilId(sp,r[0],r[1],r[2],r[3])," + "w.PGPUI.veilIds(sp,r[0],r[1],r[2],r[3])])))") def node_veil(js_path): - """veilId() of core.js at JS_PATH on VEIL_ROWS, under node: [ids] or the error.""" + """[veilId(), veilIds()] of core.js at JS_PATH for each of VEIL_ROWS, under node, or the error.""" node = shutil.which("node") if not node: return "no node" @@ -466,12 +467,14 @@ def node_veil(js_path): def veil_js_problems(js_path): - """What veilId() of core.js answers differently from veil_id() on VEIL_ROWS.""" - want = [live_results.veil_id([tuple(x) for x in VEIL_SPANS], *r) for r in VEIL_ROWS] + """What veilId() of core.js answers differently from veil_id() on VEIL_ROWS, and veilIds() from veil_id() taken + one closed result at a time (every result the row belongs to).""" + spans = [tuple(x) for x in VEIL_SPANS] + want = [[live_results.veil_id(spans, *r), [s_[0] for s_ in spans if live_results.veil_id([s_], *r)]] for r in VEIL_ROWS] got = node_veil(js_path) if got != want: - return [f"veilId {got} != veil_id {want}"] - if len({w for w in want}) < 4: + return [f"veilId, veilIds {got} != veil_id {want}"] + if len({w[0] for w in want}) < 4 or not any(len(w[1]) > 1 for w in want): return [f"the rows exercise too few answers: {want}"] return [] @@ -598,11 +601,14 @@ def tagged(sample_dir): "chart's gene panel none, and results.json carries the closed results as veil.spans", not bad, bad) check("8. node is on the PATH (ubuntu-latest has it)", shutil.which("node") is not None) bad = veil_js_problems(CORE_JS) - check("8. core.js veilId() tags each family-page row as veil_id() does (under node)", not bad, bad) + check("8. core.js veilId() tags each family-page row as veil_id() does, and veilIds() with every result " + "veil_id() finds one result at a time (under node)", not bad, bad) with open(CORE_JS) as f: core = f.read() for a, z, desc in (("if (s[1] && words[s[1]]) { return s[0]; }", "", "veilId() without its gene rule"), - ("l[1] <= s[4] && l[2] >= s[3]", "false", "veilId() without its overlap rule")): + ("l[1] <= s[4] && l[2] >= s[3]", "false", "veilId() without its overlap rule"), + (".map(function (s) { return s[0]; });\n }", ".map(function (s) { return s[0]; }).slice(0, 1);\n }", + "veilIds() keeping only the first result")): planted = os.path.join(work, "core-planted.js") with open(planted, "w") as f: f.write(core.replace(a, z)) From 40c41109ae6653c3276944cb058d7aff21e27a23 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:38:48 +0100 Subject: [PATCH 05/27] fix(live): close position-only family rows with a gene-only result, and open the map alone A closed result known by its gene alone (two changes in one gene) could sit at any de novo candidate or painted stretch, which name no gene, so those rows stay closed with it. 'Show the map' now opens the drawn map only, not the child's results. Bead: pgp-iqon.24 --- assets/live/family-page.js | 22 ++++++---- docs/live-site.md | 2 +- tests/test_live_demo.py | 88 +++++++++++++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 1253b4d59..99b5b3237 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -80,7 +80,7 @@ // Opened results are kept for the life of this page, as on the child's page: a reload closes them again. They // are kept per child and per run (two children can carry the same result, under the same id). var UNKNOWN = '?'; - var kids = {}, opened = {}, openedAll = {}, last = null; + var kids = {}, opened = {}, openedAll = {}, mapShown = {}, last = null; function openKey(child, id) { return (kids[child] && kids[child].url) + '|' + child + '|' + id; } function loadKids(f) { (f.state.children || []).forEach(function (c) { @@ -115,11 +115,16 @@ }); } // The closed results a row of CHILD belongs to: their ids, [UNKNOWN] when this page cannot tell, or []. - function tagOf(child, text, chrom, pos, end) { + // COORDS: the row has a position and no gene name (a de novo candidate, a stretch). A closed result known by its + // gene alone (two changes in one gene, with no position) could sit at that position, and only the gene would + // tell: such a row stays closed with every result of that kind too. + function tagOf(child, text, chrom, pos, end, coords) { var k = kids[child]; if (!k || k.status !== 'known') { return [UNKNOWN]; } if (!k.spans) { return k.acts.length ? [UNKNOWN] : []; } // results without the tags: cannot tell - return U.veilIds(k.spans, text, chrom, pos, end); + var ids = U.veilIds(k.spans, text, chrom, pos, end); + if (coords) { k.spans.forEach(function (s) { if (!s[2] && ids.indexOf(s[0]) < 0) { ids.push(s[0]); } }); } + return ids; } // The results of TAGS still closed for CHILD: a row stays closed while one of them is. function stillClosed(child, tags) { @@ -367,7 +372,7 @@ // A stretch drawn from a grandparent says which grandparent's copy the child got there, so one over a // closed result says whether the child got it: it stays closed. A stretch from a parent alone says nothing. var split = veiled(child, all, function (r) { - return r.ancestor && r.ancestor !== '.' ? tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end) : []; + return r.ancestor && r.ancestor !== '.' ? tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end, true) : []; }); var rows = split.open; var lengths = f.chrom_lengths || []; @@ -458,10 +463,11 @@ // closed (or this page cannot tell), it stays closed whole. var k = kids[c.child] || {}; var all = k.status !== 'known' || (!k.spans && k.acts.length) ? [UNKNOWN] : (k.spans || []).map(function (s) { return s[0]; }); - var shut = stillClosed(c.child, all); - if (shut.length) { + var shut = stillClosed(c.child, all), mk = openKey(c.child, 'map'); + // "Show the map" opens the map only: each result keeps its own closed rows until it is opened. + if (shut.length && !mapShown[mk]) { add(d, U.veil('The chromosome map of ' + c.child + ' stays closed while a result to act on in adult life is: ' + - 'it could show which copy the child got there.', 'Show the map', function () { shut.forEach(function (t) { if (t === UNKNOWN) { openedAll[c.child] = true; } else { opened[openKey(c.child, t)] = true; } }); redraw(); })); + 'it could show which copy the child got there.', 'Show the map', function () { mapShown[mk] = true; redraw(); })); } else { add(d, el('img', { src: c.segments, alt: 'Chromosome map of ' + c.child, 'class': 'segments' })); } @@ -471,7 +477,7 @@ var dn = (f.de_novo || []).filter(function (r) { return r.child === c.child; }); add(d, el('h3', {}, 'De novo candidates')); if (dn.length) { - var dv = veiled(c.child, dn, function (r) { return tagOf(c.child, r.chrom + ':' + r.pos + ' ' + r.ref + '>' + r.alt, r.chrom, r.pos); }); + var dv = veiled(c.child, dn, function (r) { return tagOf(c.child, r.chrom + ':' + r.pos + ' ' + r.ref + '>' + r.alt, r.chrom, r.pos, null, true); }); 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'], diff --git a/docs/live-site.md b/docs/live-site.md index 4634555ab..a06b94f3a 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -72,7 +72,7 @@ The header search finds a gene or rsID across the results and opens the card tha **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//`). 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one, and a stretch painted from a grandparent over one (it says which grandparent's copy the child got there), stay closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. Each click opens one result, a row that names two closed results stays closed until both are opened, and a reload closes them again. Opening a result opens it for that child only. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, a request that failed, or results the server could not build), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one, and a stretch painted from a grandparent over one (it says which grandparent's copy the child got there), stay closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. Each click opens one result, a row that names two closed results stays closed until both are opened, and a reload closes them again. Opening a result opens it for that child only. A result known by its gene alone, with no position, could sit at any de novo candidate or painted stretch, which name no gene: those rows stay closed with it. When the stretches come only as the drawn image, the image stays closed while a result is, and "Show the map" opens the image and no result. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, a request that failed, or results the server could not build), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. On a phone the grid becomes a list of pairs. 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); under 700 px the report is short: the findings of sections 1 to 4 (to act on, medicines, family, worth a look) stay open, while the cards behind them, every information card and the bodies of sections 5 to 10 start closed, each behind one line that says what it holds (section 5 stays open when it holds a finding about data quality, or a card the report flags with no finding of its own). On a child's page, and while the family's roles are not known, the findings to act on stay behind "Show the result" on a phone too. A fold you open stays open when the page is drawn again, the phone's folds too when you turn the phone to a wide screen and back. The counts read one line per level, and the "How to read this" note and the links between findings are left out. A link to a card, a section or a finding, a search hit and a tab open the folds around it (a Ctrl, Cmd or Shift click leaves this page as it is); 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. `tests/demo/render_pictures.py --out DIR --only live-phone` draws the picture below from the demo. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 2fa60be1a..36e0f5559 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -60,7 +60,10 @@ count until "Show the result" is clicked there, a row that names both planted results stays closed until both are opened (one per click), each row then comes back, and a reload closes them again; with the son's results.json - unavailable every row of his that could name one stays closed; on his page + unavailable, or answered as unreadable, every row of his that could name one + stays closed; with a closed result known by its gene alone his de novo rows + (a position, no gene) stay closed with it; his map as an image stays closed, + and "Show the map" opens the map and no result; on his page a CHIP call and a copy-number dot in the gene of a closed result stay out of their drawings until it is opened; DEMO-001's page mid-replay and at the end leads with the report (a short run band above @@ -74,7 +77,8 @@ without its filter; for the planted results, results.json without its veil tags, person.js whose table rows ignore them, person.js whose findings ignore them, charts.js that keeps every mark, search.js that ignores a hit's tag and finding.js whose findings ignore their tags; for the family page, family-page.js whose rows ignore the veil -and core.js whose veilId() tags nothing; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the +core.js whose veilId() tags nothing, family-page.js without the gene-only fallback and a "Show the map" that opens +the results too; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the folds (a flagged section 5 card folded, a redraw that forgets the folds, a Ctrl+click that opens one) and the person page's layout (a wire showing every line or every plain finish, a card showing every line, a step timeline left open) also run against a planted mistake and must @@ -812,6 +816,52 @@ def family_unknown(b, base): return out +def son_tables(b): + """[rows of each table] in the son's fold of the family page.""" + rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " + "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") + return json.loads(rows) if rows else None + + +def gene_only_problems(b, base): + """[problems] of the family page when the son also has a closed result known by its gene alone: a de novo row + shown before it is opened, a pair in another gene closed, or the rows not back once each result is opened.""" + family_page(b, base) + out = [] + t = son_tables(b) + shown = b.eval(FAMILY_SHOWN_JS) or "" + # Open: the stretches' table and the two pairs in other genes; closed: both de novo rows. + if t != [80, 2] or "48211904" in shown: + out.append(f"before any click: tables {t}") + for _ in range(3): # the gene-only result, then the two planted ones (the pair in KCNH2) + b.eval("var x = document.querySelector('details[data-child=\"DEMO-003\"] .veil button'); x && x.click(); true") + time.sleep(0.4) + t = son_tables(b) + if t != [80, 2, 3]: + out.append(f"after opening each result: tables {t}") + return out + + +def map_problems(b, base): + """[problems] of the family page whose son has his map as an image: the image shown while a result is closed, + or 'Show the map' opening a result's rows.""" + family_page(b, base) + out = [] + img = b.eval("!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')") + if img: + out.append("the map shown while a result is closed") + b.eval("var v = Array.prototype.filter.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil'), " + "function (x) { return x.textContent.indexOf('Show the map') >= 0; })[0]; v && v.querySelector('button').click(); true") + time.sleep(0.4) + img = b.eval("!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')") + shown = b.eval(FAMILY_SHOWN_JS) or "" + if not img: + out.append("'Show the map' did not show the map") + if "KCNH2" in shown or PAIR_B in shown: + out.append("'Show the map' opened the closed pair too") + return out + + def family_veil_checks(b, base, out): """On the family page a child's rows that name a closed result stay closed as on the child's page, until that result is opened there; a page that cannot read the child's results keeps every row of his that could closed.""" @@ -872,6 +922,40 @@ def write(p, text): bad = family_unknown(b, base) check("browser: with the son's results answered as unreadable (a note, no findings) the family page keeps every " "row of his that could name a result to act on closed", not bad, bad) + + # A closed result known by its gene alone: a row with only a position (a de novo candidate) could be it. + res = json.loads(keep[rj]) + res["veil"]["spans"].append(["two-hits-g0999", "G0999", None, None, None]) + write(rj, json.dumps(res)) + write(fj, keep[fj]) # the fixture's family: two de novo rows, three pairs (one of them in KCNH2) + bad = gene_only_problems(b, base) + check("browser: with a closed result known by its gene alone, the son's de novo rows (a position, no gene) stay " + "closed until it is opened, and his pairs in other genes stay open", not bad, bad) + a = "if (coords) {" + if a not in keep[fp]: + check("control: family-page.js without the gene-only fallback (the planted line is in family-page.js)", False, a) + else: + write(fp, keep[fp].replace(a, "if (false) {")) + control("family-page.js without the gene-only fallback", not gene_only_problems(b, base)) + write(fp, keep[fp]) + write(rj, keep[rj]) + + # The drawing as an image (no stretches in family.json): "Show the map" opens the map and no result. + fam = json.loads(keep[fj]) + fam["segments"] = {} + fam["state"]["children"][0]["segments"] = fam["state"]["pedigree"] + write(fj, json.dumps(fam)) + bad = map_problems(b, base) + check("browser: the son's map as an image stays closed while a result is, and 'Show the map' opens the map " + "and leaves each result's rows closed", not bad, bad) + a = "function () { mapShown[mk] = true; redraw(); }" + if a not in keep[fp]: + check("control: a 'Show the map' that opens the results too (the planted line is in family-page.js)", False, a) + else: + write(fp, keep[fp].replace(a, "function () { mapShown[mk] = true; shut.forEach(function (t) { " + "opened[openKey(c.child, t)] = true; }); redraw(); }")) + control("a 'Show the map' that opens the results too", not map_problems(b, base)) + write(fp, keep[fp]) finally: keep.pop("planted", None) for p, text in keep.items(): From 996de9d7dc485876302fd370a50acb43456ac650 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:42:29 +0100 Subject: [PATCH 06/27] fix(live): close a child's side-by-side rows and the grandparents of the painting with a closed result The side by side showed a child's CPIC row (RYR1, CACNA1S) for a gene whose result is closed on the child's page, and the painting drew a closed stretch hatched at its own place, which pointed at the result. A side-by-side row that names a child's closed result is now left out until opened there (every medicines and HLA row while the child's results cannot be read), and while a grandparent stretch lies over a closed result every stretch of that child is drawn from its parent alone. --- CHANGELOG.md | 2 +- assets/live/family-page.js | 59 +++++++++++++++++------ assets/live/live.css | 1 - docs/live-site.md | 2 +- tests/test_live_demo.py | 75 +++++++++++++++++++++++++---- tests/test_owngenome_live_family.py | 6 +++ 6 files changed, 118 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83143e76b..69de5401e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Each release's notes. From 1.0.0 on, a major or minor release has hand-written n ## Unreleased -- The family page now keeps a child's results to act on closed, as the child's own page does. A de novo candidate or a compound-het pair that names a closed result, and a stretch painted from a grandparent over one, stay behind "Show the result" until that result is opened there; a reload closes them again. While the page cannot read the child's results, every such row of that child stays closed. On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: the files behind the pages hold every row. +- The family page now keeps a child's results to act on closed, as the child's own page does. A de novo candidate or a compound-het pair that names a closed result, and a side-by-side row whose value in the child's column names one (a CPIC gene such as RYR1), stay behind "Show the result" until that result is opened there; a reload closes them again. While a stretch painted from a grandparent lies over a closed result, the child's painting shows every stretch from its parent alone. While the page cannot read the child's results, every such row of that child stays closed (in the side by side, every medicines and HLA row). On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: the files behind the pages hold every row. - Step 49 (clonal haematopoiesis) now prepares the CHIP gene models and writes its two tables with the Rust `og` binary: `og chip prepare`, `og chip report` and `og chip check-table` replace the commands of `bin/chip_report.py`, which keeps only the detection limit and the column names the reports import. On Ensembl 116's full GFF3, prepare takes 1.7 s and 6 MB instead of 15 s and 20 MB. The two tables, `chip.gff3` and `chip.bed` are the same, byte for byte. What changes: a missing input file or a position that is not a whole number stops with an `ERROR:` line and exit 1, not a Python traceback, and each output is written whole or not at all. Step 49 now needs `og` (`scripts/setup.sh --og`). - VEP consequence ranking (steps 23 and 31) and the gnomAD constraint join now run in the Rust `og` binary, inside `BCFTOOLS_IMAGE`: `og vcf csq-rank` replaces `bin/csq_rank.awk` and `og table constraint-join` replaces `bin/constraint_join.awk`, which are gone. Neither awk program runs on the host anymore (step 23 still uses the host's `awk` for small table edits), and the ranker is no longer the slow stage of step 23's main pipe. Steps 23 and 31 now need `og`: `scripts/setup.sh --og` builds it. Their output is the same, byte for byte, except for these changes. diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 99b5b3237..2bbef5a13 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -8,7 +8,8 @@ // the drawing through createElementNS; images from this server only. // A child's results to act on stay closed here as on the child's own page (person.js): each child's results.json // gives the closed results (veil.spans), and a row of the child that names one (a de novo or compound-het -// candidate, a stretch painted from a grandparent) stays closed until that result is opened on purpose here. +// candidate, a side-by-side row) stays closed until that result is opened on purpose here; while a stretch +// painted from a grandparent lies over one, the child's painting shows each stretch's parent only. (function () { 'use strict'; var U = window.PGPUI; @@ -80,6 +81,7 @@ // Opened results are kept for the life of this page, as on the child's page: a reload closes them again. They // are kept per child and per run (two children can carry the same result, under the same id). var UNKNOWN = '?'; + var GENE_GROUPS = { Medicines: true, HLA: true }; // the side-by-side groups whose rows name a gene var kids = {}, opened = {}, openedAll = {}, mapShown = {}, last = null; function openKey(child, id) { return (kids[child] && kids[child].url) + '|' + child + '|' + id; } function loadKids(f) { @@ -370,11 +372,18 @@ var SIDE = { paternal: 'father', maternal: 'mother' }; function painting(f, child, all) { // A stretch drawn from a grandparent says which grandparent's copy the child got there, so one over a - // closed result says whether the child got it: it stays closed. A stretch from a parent alone says nothing. + // closed result says whether the child got it. A stretch from a parent alone says nothing. Closing only that + // stretch would point at the closed result (a hatch, a gap in the table), so while one is closed every stretch + // of this child is drawn from its parent alone, and no grandparent, basis or closed mark is shown. var split = veiled(child, all, function (r) { return r.ancestor && r.ancestor !== '.' ? tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end, true) : []; }); - var rows = split.open; + var shut = []; + split.closed.forEach(function (t) { t.forEach(function (x) { if (shut.indexOf(x) < 0) { shut.push(x); } }); }); + var rows = !shut.length ? all : all.map(function (r) { + return { side: r.side, parent: r.parent, chrom: r.chrom, start: r.start, end: r.end, sites: r.sites, + discordant: r.discordant, ancestor: '.', basis: 'parent' }; + }); var lengths = f.chrom_lengths || []; var longest = Math.max.apply(null, lengths.map(function (x) { return x[1]; }).concat([1])); var colours = Object.create(null), next = 1; @@ -393,14 +402,6 @@ ['paternal', 'maternal'].forEach(function (side) { var track = el('span', { 'class': 'paint-track ' + side }); track.style.setProperty('--len', (100 * cl[1] / longest).toFixed(2) + '%'); - all.filter(function (r) { return r.chrom === chrom && r.side === side && rows.indexOf(r) < 0; }).forEach(function (r) { - var a = parseInt(r.start, 10), b = parseInt(r.end, 10); - if (!isFinite(a) || !isFinite(b) || b < a) { return; } - var seg = el('span', { 'class': 'paint-seg closed', title: 'A closed stretch' }); - 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); - }); 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; } @@ -425,10 +426,18 @@ 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).' + - (split.closed.length ? ' Hatched: a closed stretch.' : ''))); + add(leg, el('span', { 'class': 'dim' }, ' Grey: no stretch called (no informative site, a gap of the reference).')); add(fig, leg); - add(fig, veilBox(child, split.closed, 'stretch', 'stretches')); + if (shut.length) { + // One box for the whole painting: a count of closed stretches would say how many sit over the result. + add(fig, shut[0] === UNKNOWN + ? U.veil('The grandparents of ' + child + '’s stretches stay hidden: this page cannot read ' + child + '’s results yet, ' + + 'so it cannot tell which stretch lies over a result to act on in adult life. Each stretch shows its parent only.', + 'Show the grandparents', function () { openedAll[child] = true; redraw(); }) + : U.veil('The grandparents of ' + child + '’s stretches stay hidden while a result to act on in adult life is ' + + 'closed: they could show which copy the child got there. Each stretch shows its parent only.', null, + function () { opened[openKey(child, shut[0])] = true; redraw(); })); + } 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'], @@ -510,9 +519,25 @@ 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.')); + // A child's value that names a closed result (a CPIC gene that is also the gene of a result to act on, RYR1 or + // CACNA1S) closes its whole row, as the child's own page closes that row of its medicines card: a closed cell + // under the gene's name would say which gene it is. While this page cannot read a child's results, every row + // that names a gene (the medicines and HLA rows) stays closed; the haplogroups and blood groups name none. + var kidsHere = (f.state.children || []).map(function (c) { return c.child; }); + var closedBy = {}, shown = []; + sb.rows.forEach(function (r) { + var open = true; + sb.members.forEach(function (m, i) { + if (kidsHere.indexOf(m) < 0) { return; } + var t = stillClosed(m, tagOf(m, r.label + ' ' + (r.values[i] || ''))); + if (t[0] === UNKNOWN && !GENE_GROUPS[r.group]) { return; } + if (t.length) { (closedBy[m] = closedBy[m] || []).push(t); open = false; } + }); + if (open) { shown.push(r); } + }); // A value from a section older than its input (state "stale") says so in its own cell. var group = null, rows = [], old = 0; - sb.rows.forEach(function (r) { + shown.forEach(function (r) { rows.push([r.group === group ? '' : r.group, r.label].concat(r.values.map(function (v, i) { if (!(r.stale || [])[i]) { return v; } old++; @@ -521,6 +546,10 @@ group = r.group; }); add(s, U.table({ columns: ['', 'Gene or system'].concat(sb.members), rows: rows }, 14)); + sb.members.forEach(function (m) { + var box = closedBy[m] && veilBox(m, closedBy[m], 'row', 'rows'); + if (box) { box.setAttribute('data-veil-child', m); add(s, box); } + }); if (old) { add(s, el('p', { 'class': 'dim', 'data-stale': String(old) }, 'Marked' + STALE + ': the member’s result file is from an ' + 'earlier run, and the latest run did not write it again; that member’s page says why.')); diff --git a/assets/live/live.css b/assets/live/live.css index 4adff6602..5dba9f176 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -607,7 +607,6 @@ ul.rel-pairs .chip { white-space: normal; } .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-seg.closed { background: repeating-linear-gradient(-45deg, rgba(143, 141, 134, .45) 0 2px, transparent 2px 6px); } .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; } diff --git a/docs/live-site.md b/docs/live-site.md index a06b94f3a..d2f6ecf85 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -72,7 +72,7 @@ The header search finds a gene or rsID across the results and opens the card tha **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//`). 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one, and a stretch painted from a grandparent over one (it says which grandparent's copy the child got there), stay closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. Each click opens one result, a row that names two closed results stays closed until both are opened, and a reload closes them again. Opening a result opens it for that child only. A result known by its gene alone, with no position, could sit at any de novo candidate or painted stretch, which name no gene: those rows stay closed with it. When the stretches come only as the drawn image, the image stays closed while a result is, and "Show the map" opens the image and no result. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, a request that failed, or results the server could not build), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one stays closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. A stretch painted from a grandparent over one says which grandparent's copy the child got there: while such a stretch exists, every stretch of that child is drawn and listed from its parent alone, with no grandparent, so the painting does not point at where the result lies, and "Show the result" brings the grandparents back. Each click opens one result, a row that names two closed results stays closed until both are opened, and a reload closes them again. Opening a result opens it for that child only. A result known by its gene alone, with no position, could sit at any de novo candidate or painted stretch, which name no gene: those rows stay closed with it. When the stretches come only as the drawn image, the image stays closed while a result is, and "Show the map" opens the image and no result. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, a request that failed, or results the server could not build), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. A row whose value in a child's column names a closed result of that child (a CPIC gene such as RYR1 that is also the gene of a result to act on) is left out whole, for every member, until that result is opened here: a closed cell under the gene's name would say which gene it is. While the page cannot read a child's results, every medicines and HLA row stays closed; the haplogroups and blood groups name no gene. On a phone the grid becomes a list of pairs. 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); under 700 px the report is short: the findings of sections 1 to 4 (to act on, medicines, family, worth a look) stay open, while the cards behind them, every information card and the bodies of sections 5 to 10 start closed, each behind one line that says what it holds (section 5 stays open when it holds a finding about data quality, or a card the report flags with no finding of its own). On a child's page, and while the family's roles are not known, the findings to act on stay behind "Show the result" on a phone too. A fold you open stays open when the page is drawn again, the phone's folds too when you turn the phone to a wide screen and back. The counts read one line per level, and the "How to read this" note and the links between findings are left out. A link to a card, a section or a finding, a search hit and a tab open the folds around it (a Ctrl, Cmd or Shift click leaves this page as it is); 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. `tests/demo/render_pictures.py --out DIR --only live-phone` draws the picture below from the demo. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 36e0f5559..db9b5f30b 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -59,9 +59,12 @@ name its gene, positions and that grandparent in no row, link, title or count until "Show the result" is clicked there, a row that names both planted results stays closed until both are opened (one per click), each - row then comes back, and a reload closes them again; with the son's results.json + row then comes back, and a reload closes them again; while a stretch painted + from a grandparent lies over one, every stretch of his is drawn and listed from + its parent alone, none marked; with a closed result of his in RYR1 the side by + side's RYR1 row stays closed until opened there; with the son's results.json unavailable, or answered as unreadable, every row of his that could name one - stays closed; with a closed result known by its gene alone his de novo rows + stays closed (in the side by side every medicines and HLA row); with a closed result known by its gene alone his de novo rows (a position, no gene) stay closed with it; his map as an image stays closed, and "Show the map" opens the map and no result; on his page a CHIP call and a copy-number dot in the gene of a closed result stay out @@ -77,8 +80,8 @@ without its filter; for the planted results, results.json without its veil tags, person.js whose table rows ignore them, person.js whose findings ignore them, charts.js that keeps every mark, search.js that ignores a hit's tag and finding.js whose findings ignore their tags; for the family page, family-page.js whose rows ignore the veil -core.js whose veilId() tags nothing, family-page.js without the gene-only fallback and a "Show the map" that opens -the results too; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the +core.js whose veilId() tags nothing, family-page.js without the gene-only fallback, a "Show the map" that opens +the results too, a closed painting that keeps each stretch's grandparent and a side by side that ignores the veil; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the folds (a flagged section 5 card folded, a redraw that forgets the folds, a Ctrl+click that opens one) and the person page's layout (a wire showing every line or every plain finish, a card showing every line, a step timeline left open) also run against a planted mistake and must @@ -768,10 +771,14 @@ def family_closed(b, base): rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") closed = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") - segs = b.eval("document.querySelectorAll('[data-painting=\"DEMO-003\"] .paint-seg.closed').length") - # The stretches' table, the 2 de novo rows of the fixture and its 2 other pairs stay; one closed box each. - if not rows or json.loads(rows)[1:] != [2, 2] or closed != 3 or segs != 1: - out.append(f"the open rows and the closed boxes: tables {rows}, {closed} closed boxes, {segs} closed stretches") + segs = b.eval("document.querySelectorAll('[data-painting=\"DEMO-003\"] .paint-seg').length") + marked = b.eval("document.querySelectorAll('[data-painting=\"DEMO-003\"] .paint-seg.closed, " + "[data-painting=\"DEMO-003\"] .paint-seg.other').length") + # The 2 de novo rows of the fixture and its 2 other pairs stay, one closed box each; the painting closes whole + # (one box): every stretch drawn and listed from its parent, none marked, so none points at the closed result. + if not rows or json.loads(rows)[1:] != [2, 2] or closed != 3 or json.loads(rows)[0] != segs or marked: + out.append(f"the open rows and the closed boxes: tables {rows}, {closed} closed boxes, {segs} stretches drawn, " + f"{marked} marked") return out @@ -813,6 +820,38 @@ def family_unknown(b, base): "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") if rows is None or len(json.loads(rows)) != 1: out.append(f"the son's de novo or compound-het rows open with his results unavailable: tables {rows}") + side = side_labels(b) + if any(g in side for g in SIDE_GENES) or "Mitochondrial" not in side: + out.append(f"side by side with the son's results unavailable: rows {sorted(side)[:8]}") + return out + + +SIDE_GENES = ("RYR1", "CACNA1S", "CYP2D6", "HLA-A") + + +def side_labels(b): + """The 'Gene or system' column of the family page's side by side.""" + got = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('#ch-side-by-side table.rows tbody tr'), " + "function (r) { return r.children[1] ? r.children[1].textContent : ''; }))") + return json.loads(got) if got else [] + + +def side_problems(b, base): + """[problems] of the family page's side by side with a closed result of the son's in RYR1: the RYR1 row (his + diplotype and phenotype under the gene's name) shown before it is opened there, the other rows closed with it, + or the row not back once opened.""" + family_page(b, base) + out = [] + labels = side_labels(b) + shown = b.eval("document.getElementById('ch-side-by-side').textContent") or "" + boxes = b.eval("document.querySelectorAll('#ch-side-by-side .veil[data-veil-child=\"DEMO-003\"]').length") + if "RYR1" in shown or "CACNA1S" not in labels or boxes != 1: + out.append(f"before any click: RYR1 {'shown' if 'RYR1' in shown else 'closed'}, {len(labels)} rows, {boxes} closed boxes") + b.eval("var x = document.querySelector('#ch-side-by-side .veil button'); x && x.click(); true") + time.sleep(0.4) + labels2 = side_labels(b) + if "RYR1" not in labels2 or len(labels2) != len(labels) + 1: + out.append(f"after 'Show the result': {len(labels2)} rows, RYR1 {'back' if 'RYR1' in labels2 else 'still closed'}") return out @@ -900,7 +939,9 @@ def write(p, text): for p, a, z, desc in ( (fp, "return tags.filter(function (t) {", "return [].filter(function (t) {", "family-page.js whose rows ignore the veil"), - (cj, " if (!spans || !spans.length) { return null; }", " return null;", "core.js whose veilId() tags nothing")): + (cj, " if (!spans || !spans.length) { return null; }", " return null;", "core.js whose veilId() tags nothing"), + (fp, "ancestor: '.', basis: 'parent' };", "ancestor: r.ancestor, basis: r.basis };", + "a closed painting that keeps each stretch's grandparent")): if a not in keep[p]: check(f"control: {desc} (the planted line is in {os.path.basename(p)})", False, a) continue @@ -940,6 +981,22 @@ def write(p, text): write(fp, keep[fp]) write(rj, keep[rj]) + # A closed result in a CPIC gene: the side by side lists that gene with the son's diplotype in his column. + res = json.loads(keep[rj]) + res["veil"]["spans"].append(["sf-ryr1-demo", "RYR1", "chr19", 38440000, 38440000]) + write(rj, json.dumps(res)) + bad = side_problems(b, base) + check("browser: with a closed result of the son's in RYR1, the side by side's RYR1 row (his diplotype and " + "phenotype) stays closed until it is opened there, and the other rows stay", not bad, bad) + a = "var t = stillClosed(m, tagOf(m, r.label + ' ' + (r.values[i] || '')));" + if a not in keep[fp]: + check("control: a side by side that ignores the veil (the planted line is in family-page.js)", False, a) + else: + write(fp, keep[fp].replace(a, "var t = [];")) + control("a side by side that ignores the veil", not side_problems(b, base)) + write(fp, keep[fp]) + write(rj, keep[rj]) + # The drawing as an image (no stretches in family.json): "Show the map" opens the map and no result. fam = json.loads(keep[fj]) fam["segments"] = {} diff --git a/tests/test_owngenome_live_family.py b/tests/test_owngenome_live_family.py index 8cb7c8678..5965c1045 100755 --- a/tests/test_owngenome_live_family.py +++ b/tests/test_owngenome_live_family.py @@ -349,6 +349,12 @@ def browser_checks(port, work): couple and "1 gene(s) where both carry a finding, 1 X-linked" in couple and b.eval("!!document.querySelector('p[data-couple=\\'father_a mother_a\\'] a')"), couple) b.eval("document.querySelector('details[data-child=child_a]').open = true; true") + # child_a's results cannot be built here (its summary is cut short), so the page cannot tell whether a + # result to act on is closed: the map stays closed until "Show the map" is clicked. + shut = b.wait("var v = Array.prototype.filter.call(document.querySelectorAll('details[data-child=child_a] .veil'), " + "function (x) { return x.textContent.indexOf('Show the map') >= 0; })[0]; " + "v && !document.querySelector('details[data-child=child_a] img') && (v.querySelector('button').click(), true)", 10) + check("browser: child_a's chromosome map stays closed while its results cannot be read", shut, shut) seg = b.wait("var i = document.querySelector('details[data-child=child_a] img'); " "i && i.complete && i.naturalWidth > 0 && i.getAttribute('src')", 10) check("browser: child_a's chromosome map loads", seg == "/family/family_a/child_a_segments.svg", seg) From 0bb490670b33a768a9b83e25125ff7a951e0000a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:48:27 +0100 Subject: [PATCH 07/27] fix(live): open a child's drawing only by its own button while the page cannot read the child's results 'Show the rows' of a table opened the child's map as an image, and the painting's grandparents, too. --- assets/live/family-page.js | 18 +++++++++----- tests/test_live_demo.py | 49 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 2bbef5a13..174352935 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -132,6 +132,11 @@ function stillClosed(child, tags) { return tags.filter(function (t) { return t === UNKNOWN ? !openedAll[child] : !opened[openKey(child, t)]; }); } + // The same for a whole drawing of CHILD (its painting, its map as an image): while this page cannot read the + // child's results, only the drawing's own button (KEY) opens it, never "Show the rows" of a table. + function drawingClosed(child, tags, key) { + return tags.filter(function (t) { return t === UNKNOWN ? !mapShown[openKey(child, key)] : !opened[openKey(child, t)]; }); + } // The rows of CHILD split by the veil: { open: rows, closed: [the still closed results of each closed row] }. function veiled(child, rows, tagFn) { var out = { open: [], closed: [] }; @@ -375,11 +380,12 @@ // closed result says whether the child got it. A stretch from a parent alone says nothing. Closing only that // stretch would point at the closed result (a hatch, a gap in the table), so while one is closed every stretch // of this child is drawn from its parent alone, and no grandparent, basis or closed mark is shown. - var split = veiled(child, all, function (r) { - return r.ancestor && r.ancestor !== '.' ? tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end, true) : []; + var shut = [], pk = openKey(child, 'paint'); + all.forEach(function (r) { + if (!r.ancestor || r.ancestor === '.') { return; } + drawingClosed(child, tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end, true), 'paint') + .forEach(function (x) { if (shut.indexOf(x) < 0) { shut.push(x); } }); }); - var shut = []; - split.closed.forEach(function (t) { t.forEach(function (x) { if (shut.indexOf(x) < 0) { shut.push(x); } }); }); var rows = !shut.length ? all : all.map(function (r) { return { side: r.side, parent: r.parent, chrom: r.chrom, start: r.start, end: r.end, sites: r.sites, discordant: r.discordant, ancestor: '.', basis: 'parent' }; @@ -433,7 +439,7 @@ add(fig, shut[0] === UNKNOWN ? U.veil('The grandparents of ' + child + '’s stretches stay hidden: this page cannot read ' + child + '’s results yet, ' + 'so it cannot tell which stretch lies over a result to act on in adult life. Each stretch shows its parent only.', - 'Show the grandparents', function () { openedAll[child] = true; redraw(); }) + 'Show the grandparents', function () { mapShown[pk] = true; redraw(); }) : U.veil('The grandparents of ' + child + '’s stretches stay hidden while a result to act on in adult life is ' + 'closed: they could show which copy the child got there. Each stretch shows its parent only.', null, function () { opened[openKey(child, shut[0])] = true; redraw(); })); @@ -472,7 +478,7 @@ // closed (or this page cannot tell), it stays closed whole. var k = kids[c.child] || {}; var all = k.status !== 'known' || (!k.spans && k.acts.length) ? [UNKNOWN] : (k.spans || []).map(function (s) { return s[0]; }); - var shut = stillClosed(c.child, all), mk = openKey(c.child, 'map'); + var shut = drawingClosed(c.child, all, 'map'), mk = openKey(c.child, 'map'); // "Show the map" opens the map only: each result keeps its own closed rows until it is opened. if (shut.length && !mapShown[mk]) { add(d, U.veil('The chromosome map of ' + c.child + ' stays closed while a result to act on in adult life is: ' + diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index db9b5f30b..968eeaaa9 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -64,7 +64,9 @@ its parent alone, none marked; with a closed result of his in RYR1 the side by side's RYR1 row stays closed until opened there; with the son's results.json unavailable, or answered as unreadable, every row of his that could name one - stays closed (in the side by side every medicines and HLA row); with a closed result known by its gene alone his de novo rows + stays closed (in the side by side every medicines and HLA row), and "Show the + rows" opens neither his painting's grandparents nor his map as an image, which + open only by their own button; with a closed result known by its gene alone his de novo rows (a position, no gene) stay closed with it; his map as an image stays closed, and "Show the map" opens the map and no result; on his page a CHIP call and a copy-number dot in the gene of a closed result stay out @@ -81,7 +83,8 @@ rows ignore them, person.js whose findings ignore them, charts.js that keeps every mark, search.js that ignores a hit's tag and finding.js whose findings ignore their tags; for the family page, family-page.js whose rows ignore the veil core.js whose veilId() tags nothing, family-page.js without the gene-only fallback, a "Show the map" that opens -the results too, a closed painting that keeps each stretch's grandparent and a side by side that ignores the veil; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the +the results too, a closed painting that keeps each stretch's grandparent, a side by side that ignores the veil and a +drawing that "Show the rows" opens; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the folds (a flagged section 5 card folded, a redraw that forgets the folds, a Ctrl+click that opens one) and the person page's layout (a wire showing every line or every plain finish, a card showing every line, a step timeline left open) also run against a planted mistake and must @@ -829,6 +832,33 @@ def family_unknown(b, base): SIDE_GENES = ("RYR1", "CACNA1S", "CYP2D6", "HLA-A") +def click_veil(b, words): + """Click the son's closed box whose button says WORDS; whether there was one.""" + return b.eval("var v = Array.prototype.filter.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil " + "button'), function (x) { return x.textContent === " + json.dumps(words) + "; })[0]; " + "if (v) { v.click(); } !!v") + + +def unknown_drawing_problems(b, base, image): + """[problems] of the family page with the son's results unavailable: "Show the rows" of a table opening his + drawing (the map as an image, or the painting's grandparents), or the drawing's own button not opening it.""" + family_page(b, base) + out = [] + if not click_veil(b, "Show the rows"): + out.append("no 'Show the rows' box") + time.sleep(0.4) + img = "!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')" + seen = (lambda: b.eval(img)) if image else (lambda: GRANDPARENT in (b.eval(FAMILY_SHOWN_JS) or "")) + if seen(): + out.append("'Show the rows' opened the drawing too") + if not click_veil(b, "Show the map" if image else "Show the grandparents"): + out.append("no closed box of the drawing") + time.sleep(0.4) + if not seen(): + out.append("the drawing's own button did not open it") + return out + + def side_labels(b): """The 'Gene or system' column of the family page's side by side.""" got = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('#ch-side-by-side table.rows tbody tr'), " @@ -951,6 +981,7 @@ def write(p, text): os.replace(rj, rj + ".away") try: bad = family_unknown(b, base) + bad += unknown_drawing_problems(b, base, False) finally: os.replace(rj + ".away", rj) check("browser: with the son's results.json unavailable the family page keeps every row of his that could name " @@ -1005,6 +1036,20 @@ def write(p, text): bad = map_problems(b, base) check("browser: the son's map as an image stays closed while a result is, and 'Show the map' opens the map " "and leaves each result's rows closed", not bad, bad) + os.replace(rj, rj + ".away") + try: + bad = unknown_drawing_problems(b, base, True) + check("browser: with the son's results unavailable, 'Show the rows' leaves his map as an image closed, and " + "'Show the map' opens it", not bad, bad) + a = "return t === UNKNOWN ? !mapShown[openKey(child, key)] :" + if a not in keep[fp]: + check("control: a drawing that 'Show the rows' opens (the planted line is in family-page.js)", False, a) + else: + write(fp, keep[fp].replace(a, "return t === UNKNOWN ? !mapShown[openKey(child, key)] && !openedAll[child] :")) + control("a drawing that 'Show the rows' opens", not unknown_drawing_problems(b, base, True)) + write(fp, keep[fp]) + finally: + os.replace(rj + ".away", rj) a = "function () { mapShown[mk] = true; redraw(); }" if a not in keep[fp]: check("control: a 'Show the map' that opens the results too (the planted line is in family-page.js)", False, a) From 097f6c13bc4ad68d8b6c835662ed60c86165e5ec Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:26:40 +0100 Subject: [PATCH 08/27] test(live): check that each demo person page draws the genome map The demo pages must draw Figure 1 from the og map document, place every finding the old karyogram placed, keep closed results off the map, name missing layers, and fall back to a table without a map. The checks run against main first, so they fail until the map lands. Bead: pgp-iqon.8 --- tests/demo/build_live_demo.py | 72 ++++- tests/fixtures/map/karyogram-marks.tsv | 16 + tests/test_live_demo.py | 415 ++++++++++++++++++++++++- tests/test_live_results.py | 50 ++- 4 files changed, 541 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/map/karyogram-marks.tsv diff --git a/tests/demo/build_live_demo.py b/tests/demo/build_live_demo.py index f30a624b8..bee9cdb78 100644 --- a/tests/demo/build_live_demo.py +++ b/tests/demo/build_live_demo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """build_live_demo.py: the live site's public demo, a static folder replaying an invented run. - build_live_demo.py --out DIR [--genome-dir GDIR] [--records N] [--work WDIR] + build_live_demo.py --out DIR [--genome-dir GDIR] [--records N] [--work WDIR] [--og OG] writes DIR, which the docs workflow publishes at /demo/ of the docs site: @@ -13,6 +13,11 @@ 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/map.json the genome map of Figure 1: `og map build` with those results on + stdin, as the map plugin (bin/owngenome_live_map.py) sends it + (gzip bytes); the same for each person + map/genes/ `og map genes` of the public GENCODE slice + (tests/fixtures/map/gencode-slice), as /map/genes/ sends them 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 @@ -56,7 +61,12 @@ meta tag cannot set, left out), and the site's absolute links (/runs/..., /family/...) become relative ones; - the genome viewer opens the reader's own reads on the reader's own - computer, so the demo's viewer/ page says it is not in the demo. + computer, so the demo's viewer/ page says it is not in the demo; + - the map's genes come from the GENCODE slice copied into the run folder's + reference/, since setup has not installed GENCODE there. The map needs og + (OG, default bin/og, which scripts/setup.sh --og builds): without it the + build writes no map.json, says so, and the page lists the findings with a + place in a table instead. Standard library only. tests/test_live_demo.py builds it and checks it. """ @@ -102,6 +112,8 @@ DEMO_RAM = 64 << 30 DEMO_DISK = os.statvfs_result((4096, 4096, 512 << 20, 230 << 20, 230 << 20, 128 << 20, 100 << 20, 100 << 20, 0, 255)) INTERVAL_MS = 600 +OG = os.path.join(BIN, "og") +GENCODE_SLICE = os.path.join(REPO, "tests", "fixtures", "map", "gencode-slice", "reference") # The server's policy; a meta tag cannot set frame-ancestors (the browser ignores it there and warns). CSP = "; ".join(p for p in owngenome_live.CSP.split("; ") if not p.startswith("frame-ancestors")) @@ -167,6 +179,9 @@ def prepare(root, genome_dir=None, records=20000): other_person(root, name, sex, seed, max(2000, records // 4)) fam = os.path.join(root, FAMILY) shutil.copytree(os.path.join(EXTRA, FAMILY), fam) + # The genes the map places a gene-level finding on (and draws later): setup's GENCODE files, here the slice. + if not os.path.isdir(os.path.join(root, "reference")): + shutil.copytree(GENCODE_SLICE, os.path.join(root, "reference")) couple = os.path.join(d, "couple", f"{SAMPLE}_couple_carrier.tsv") if os.path.isfile(couple): a, b = sorted((SAMPLE, PARTNER)) @@ -383,7 +398,44 @@ def person_site(out, root, name, plugin_js, frames, results): "../../../assets/live.css")) -def write_site(out, root, frames, fam): +def og_map(og, args, stdin=b""): + """og map ARGS: its stdout, or None when og is not built on this machine (exit 127).""" + try: + p = subprocess.run([og, "map"] + args, input=stdin, capture_output=True, check=False) + except FileNotFoundError: + return None + if p.returncode == 127: + return None + if p.returncode != 0: + raise SystemExit(f"ERROR: og map {args[0]} failed ({p.returncode}): {p.stderr.decode(errors='replace').strip()}") + return p.stdout + + +def map_files(out, root, people, og): + """runs//map.json for each (name, results) of PEOPLE and map/genes/, as the map plugin sends + them; False (and a note) when og is not built here.""" + cache = os.path.join(root, ".owngenome-live", "map") + assets = os.path.join(REPO, "assets") + for name, results in people: + doc = og_map(og, ["build", "--root", root, "--run", name, "--assets", assets, "--cache-dir", cache], + json.dumps({"results": results}).encode()) + if doc is None: + print(f"NOTE: og is not built here ({og}): the demo has no genome map; scripts/setup.sh --og builds it", + file=sys.stderr) + return False + with open(os.path.join(out, "runs", name, "map.json"), "wb") as f: + f.write(doc) + gd = os.path.join(out, "map", "genes") + os.makedirs(gd) + chroms = [f"chr{c}" for c in list(range(1, 23)) + ["X", "Y"]] + for which in chroms + ["names"]: + data = og_map(og, ["genes", "--root", root, "--cache-dir", cache] + (["--names"] if which == "names" else ["--chrom", which])) + with open(os.path.join(gd, f"{which}.json"), "wb") as f: + f.write(data) + return True + + +def write_site(out, root, frames, fam, og=OG): if os.path.exists(out): shutil.rmtree(out) shutil.copytree(ASSETS, os.path.join(out, "assets"), @@ -400,6 +452,7 @@ def write_site(out, root, frames, fam): for name, _, _ in OTHERS: o = fam["others"][name] person_site(out, root, name, plugin_js, o["frames"], o["results"]) + drawn = map_files(out, root, [(SAMPLE, fam["results"])] + [(n, fam["others"][n]["results"]) for n, _, _ in OTHERS], og) fd = os.path.join(out, "runs", FAMILY) os.makedirs(fd) @@ -414,10 +467,12 @@ def write_site(out, root, frames, fam): f.write(pedigree_svg.draw(order, members, FAMILY)) for name in (fam["state"]["plugins"].get("family") or {}).get("files") or []: shutil.copy(os.path.join(ffiles, name), os.path.join(famdir, name)) + return drawn -def build(out, genome_dir=None, records=20000, work=None, drop_hook=None): - """Write the demo into out; returns {frames, hooks, family} for the tests.""" +def build(out, genome_dir=None, records=20000, work=None, drop_hook=None, og=OG): + """Write the demo into out; returns {frames, hooks, family, map} for the tests (map: the map + documents were written).""" tmp = None if work: os.makedirs(work, exist_ok=True) @@ -430,8 +485,8 @@ def build(out, genome_dir=None, records=20000, work=None, drop_hook=None): try: held = prepare(root, genome_dir, records) frames, fam, app = replay(root, held, drop_hook) - write_site(out, root, frames, fam) - return {"frames": frames, "family": fam, "hooks": [n for n, _ in app.state_hooks]} + drawn = write_site(out, root, frames, fam, og) + return {"frames": frames, "family": fam, "hooks": [n for n, _ in app.state_hooks], "map": drawn} finally: if tmp: shutil.rmtree(tmp, ignore_errors=True) @@ -443,8 +498,9 @@ def main(argv=None): ap.add_argument("--genome-dir", help="a folder holding DEMO-001 (default: write one now)") ap.add_argument("--records", type=int, default=20000, help="variant records of a DEMO-001 written now") ap.add_argument("--work", help="an empty folder for the run folder, kept afterwards (default: a temporary one)") + ap.add_argument("--og", default=OG, help="the og that builds the map documents (default: bin/og)") a = ap.parse_args(argv) - r = build(a.out, a.genome_dir, a.records, a.work) + r = build(a.out, a.genome_dir, a.records, a.work, og=a.og) size = sum(os.path.getsize(os.path.join(p, f)) for p, _, fs in os.walk(a.out) for f in fs) print(f"Demo written: {a.out} ({len(r['frames'])} frames, {size // 1024} KB; plugins {', '.join(r['hooks'])})") return 0 diff --git a/tests/fixtures/map/karyogram-marks.tsv b/tests/fixtures/map/karyogram-marks.tsv new file mode 100644 index 000000000..6104ef186 --- /dev/null +++ b/tests/fixtures/map/karyogram-marks.tsv @@ -0,0 +1,16 @@ +# The marks the findings karyogram (core.js karyogram(), person.js Figure 1) drew on the demo's person pages, +# recorded before the genome map replaced it: one row per mark, from each page at the end of its run. A mark is a +# finding with a viewer place (chrN:pos or chrN:start-end) that the page did not keep closed (DEMO-003 is a child, +# so its results to act on and the findings tagged with them were not drawn). mb is the place's first +# position in Mb, rounded to 0.01. tests/test_live_demo.py checks the map places each one. +run id chr mb level +DEMO-001 clinvar-democ-chr16-2087914-ct-c 16 2.09 review +DEMO-001 clinvar-demoa-chr2-47403191-c-t 2 47.40 review +DEMO-001 clinvar-demob-chr11-108259012-g-a 11 108.26 review +DEMO-002 clinvar-democ-chr16-2087914-ct-c 16 2.09 review +DEMO-002 clinvar-demoa-chr2-47403191-c-t 2 47.40 review +DEMO-002 clinvar-demob-chr11-108259012-g-a 11 108.26 review +DEMO-003 clinvar-democ-chr16-2087914-ct-c 16 2.09 review +DEMO-003 clinvar-demoa-chr2-47403191-c-t 2 47.40 review +DEMO-003 clinvar-demob-chr11-108259012-g-a 11 108.26 review +DEMO-003 sv-chr16-2084000-2096000 16 2.08 review diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 805606cfb..0fedd27d5 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -72,10 +72,13 @@ Run: python3 tests/test_live_demo.py """ +import base64 import copy +import gzip import json import os import shutil +import subprocess import sys import tempfile import threading @@ -314,6 +317,7 @@ def browser_checks(out): fold_checks(b, base, out) veil_checks(b, base, out) planted_checks(b, base, out) + map_checks(b, base, out) finally: b.close() srv.shutdown() @@ -1175,6 +1179,412 @@ def fold_checks(b, base, out): f.write(keep_js) +# --- the genome map (Figure 1, assets/live/map.js) ------------------------------------------ + +MARKS_TSV = os.path.join(REPO, "tests", "fixtures", "map", "karyogram-marks.tsv") + + +def og_binary(): + """An og that runs here: bin/og when scripts/setup.sh --og built it, else one cargo builds now from rust/ + without the BAM feature (the map reads no BAM), kept in CARGO_TARGET_DIR or a folder of the temp dir. + None when neither works.""" + og = os.path.join(REPO, "bin", "og") + if subprocess.run([og, "map", "--help"], capture_output=True, check=False).returncode == 0: + return og + cargo = shutil.which("cargo") or os.path.expanduser("~/.cargo/bin/cargo") + if not os.path.isfile(cargo): + return None + target = os.environ.get("CARGO_TARGET_DIR") or os.path.join(tempfile.gettempdir(), "owngenome-test-og") + p = subprocess.run([cargo, "build", "--locked", "--quiet", "-p", "og", "--no-default-features"], capture_output=True, + text=True, cwd=os.path.join(REPO, "rust"), env=dict(os.environ, CARGO_TARGET_DIR=target), check=False) + exe = os.path.join(target, "debug", "og") + if p.returncode != 0 or not os.path.isfile(exe): + print(f"cargo could not build og ({p.returncode}): {p.stderr[-2000:]}", file=sys.stderr) + return None + return exe + + +def karyogram_marks(): + """(run, id, chrom, mb, level) of each mark the karyogram drew, recorded before the map replaced it.""" + rows = [] + with open(MARKS_TSV) as f: + for line in f: + if line.startswith(("#", "run\t")) or not line.strip(): + continue + run, fid, c, m, lv = line.rstrip("\n").split("\t") + rows.append((run, fid, "chr" + c, float(m), lv)) + return rows + + +def read_map(path): + with open(path, "rb") as f: + return json.loads(gzip.decompress(f.read())) + + +def write_map(path, doc): + with open(path, "wb") as f: + f.write(gzip.compress(json.dumps(doc, separators=(",", ":")).encode(), mtime=0)) + + +# What the map shows: its state, the findings it draws (its list of marks), its timings, level and place, +# its chips, its margin's words and every text and title it holds, and the address. +MAP_JS = r"""JSON.stringify((function () { + var f = document.getElementById('genome-map'); + if (!f) { return { state: 'absent', marks: [] }; } + var t = document.querySelector('.gm-tip'); + return { state: f.dataset.pgpMap || '', level: f.dataset.level || '', chrom: f.dataset.chrom || '', view: f.dataset.view || '', + parse: Number(f.dataset.pgpMapParseMs), paint: Number(f.dataset.pgpMapPaintMs), + marks: Array.prototype.map.call(f.querySelectorAll('.gm-marks a'), function (a) { return { id: a.dataset.id, + level: a.dataset.level, chrom: a.dataset.chrom, s: Number(a.dataset.s), e: Number(a.dataset.e) }; }), + chips: Array.prototype.map.call(f.querySelectorAll('.gm-chip'), function (c) { return c.textContent; }), + title: (document.getElementById('gm-title') || {}).textContent || '', + layers: Array.prototype.map.call(f.querySelectorAll('.gm-layer'), function (l) { return [l.dataset.layer, l.dataset.state, + l.querySelector('dd').textContent]; }), + toast: (f.querySelector('.gm-toast') || { hidden: true }).hidden ? '' : f.querySelector('.gm-toast').textContent, + tip: t && !t.hidden ? t.textContent : '', + text: [f.textContent].concat(Array.prototype.map.call(f.querySelectorAll('[href],[title],[aria-label]'), function (e) { + return (e.getAttribute('href') || '') + ' ' + (e.getAttribute('title') || '') + ' ' + (e.getAttribute('aria-label') || ''); })).join(' '), + hash: location.hash }; +}()))""" +MAP_READY = ("document.documentElement.dataset.pgpResults && (function () { var f = document.getElementById('genome-map'); " + "return f && /^(drawn|failed|nodecompress)$/.test(f.dataset.pgpMap || '') && f.dataset.level !== undefined " + "&& (f.dataset.pgpMap !== 'drawn' || !!f.dataset.level); }())") + + +def map_page(b, base, run, hash_=""): + """A fresh load of RUN's page with HASH_; the map's facts once it settled (None when it never drew).""" + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{run}/{hash_}") + if not b.wait(MAP_READY): + return json.loads(b.eval(MAP_JS)) + time.sleep(0.6) # the margin follows the camera 140 ms after it settles + return json.loads(b.eval(MAP_JS)) + + +def map_now(b): + time.sleep(1.5) # the camera eases for about 0.6 s, then the margin follows 140 ms after it settles + return json.loads(b.eval(MAP_JS)) + + +def parity_problems(b, base, rows): + """The karyogram's marks the map does not place with the same level and chromosome within 0.01 Mb.""" + bad = [] + for run in sorted({r[0] for r in rows}): + got = map_page(b, base, run) + if got["state"] != "drawn": + bad.append(f"{run}: map {got['state']}") + continue + by = {m["id"]: m for m in got["marks"]} + for _, fid, c, m, lv in (r for r in rows if r[0] == run): + g = by.get(fid) + if not g: + bad.append(f"{run}: {fid} not on the map") + elif g["level"] != lv or g["chrom"] != c or abs(g["s"] / 1e6 - m) > 0.01: + bad.append(f"{run}: {fid} at {g['chrom']} {g['s'] / 1e6:.3f} Mb {g['level']}, want {c} {m} {lv}") + return bad + + +def click_canvas(b, x, y, move_only=False): + """A mouse at (x, y) of the map's canvas, in CSS px from its corner: moved there, and clicked unless MOVE_ONLY.""" + b.eval("document.querySelector('.gm-canvas').scrollIntoView({block: 'center'}); true") + time.sleep(0.3) + r = json.loads(b.eval("JSON.stringify(document.querySelector('.gm-canvas').getBoundingClientRect())")) + px, py = r["left"] + x, r["top"] + y + b.call("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": px, "y": py}, b.session) + if not move_only: + for t in ("mousePressed", "mouseReleased"): + b.call("Input.dispatchMouseEvent", {"type": t, "x": px, "y": py, "button": "left", "clickCount": 1}, b.session) + time.sleep(0.6) + + +def genome_xy(b, doc, chrom, bp): + """Where BP of CHROM sits on the genome level of the canvas (map.js gx and rowY, at the desktop width).""" + cw = b.eval("document.querySelector('.gm-canvas').clientWidth") + rows = [c for c in doc["ref"]["chroms"] if not (c["n"] == "chrY" and doc["people"][0].get("has_y") is False)] + i = [c["n"] for c in rows].index(chrom) + ch = b.eval("document.querySelector('.gm-canvas').clientHeight") + rh = max(22, min(44, (ch - 18) / len(rows))) + return 40 + bp * (cw - 40 - 22) / rows[0]["len"], 9 + i * rh + rh / 2 - 1 + + +def state_problems(b, base): + """The genome and chromosome levels, the links that open each, a click on a row, Esc, and a place that + cannot be read.""" + bad = [] + g = map_page(b, base, S) + if not (g["level"] == "genome" and "all 24 chromosomes" in g["title"] and g["view"] == "genome"): + bad.append(f"genome: {g['level']} {g['title']!r} {g['view']}") + for link, chrom, view in (("#chr16", "chr16", "chr16"), ("#chr16:10.0-40.0M", "chr16", "chr16:10.0-40.0M"), + ("#view=chromosome&chr=11", "chr11", "chr11"), ("#chr2:20000001-60000000", "chr2", "chr2:20.0-60.0M")): + c = map_page(b, base, S, link) + if not (c["level"] == "chromosome" and c["chrom"] == chrom and c["view"] == view): + bad.append(f"{link}: {c['level']} {c['chrom']} {c['view']}") + elif any(m["chrom"] != chrom for m in c["marks"]): + bad.append(f"{link}: marks of another chromosome listed: {c['marks']}") + c = map_page(b, base, S, "#chr16") + b.eval("location.hash = '#genome'; true") + c = map_now(b) + if c["level"] != "genome": + bad.append(f"#genome after #chr16: {c['level']}") + b.eval("location.hash = '#chrQ'; true") + c = map_now(b) + if "Could not read the place" not in c["toast"] or c["level"] != "genome": + bad.append(f"#chrQ: toast {c['toast']!r}, level {c['level']}") + g = map_page(b, base, S) + click_canvas(b, 200, 9 + 2 * 22 + 11) + c = map_now(b) + if not (c["level"] == "chromosome" and c["chrom"] == "chr3" and c["hash"] == "#chr3"): + bad.append(f"a click on chromosome 3's row: {c['level']} {c['chrom']} {c['hash']}") + b.eval("document.querySelector('.gm-canvas').dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); true") + c = map_now(b) + if c["level"] != "genome" or c["hash"] != "#genome": + bad.append(f"Esc: {c['level']} {c['hash']}") + return bad + + +def map_veil_problems(b, base, out): + """On the son's closed page: his closed result to act on (the planted deletion) is in no mark, tooltip, + chip, sentence, link or address of the map until opened; the chip's Open (after its confirm) and the card's + "Show the result" each bring back exactly its mark, on the map and in the card; Close it again and a reload + close it. On DEMO-001, an adult's page, APOE stays closed until opened, from the chip or its card's fold.""" + bad = [] + son = OTHERS[1] + doc = read_map(os.path.join(out, "runs", son, "map.json")) + f = doc["per"][son]["findings"] + acts = [i for i, lv in enumerate(f["level"]) if lv == "act" and not f["gate"][i]] + if not acts: + return ["the son's map has no result to act on to close"] + i = acts[0] + fid, gene = f["id"][i], f["gene"][i] + words = [gene, f"{f['chrom'][i]}:{f['s'][i] + 1}", f"{f['s'][i] + 1:,}", fid] + x, y = genome_xy(b, doc, f["chrom"][i], (f["s"][i] + f["e"][i]) / 2) + + def leaks(c): + return [w for w in words if w in c["text"] or w in c["tip"] or w in c["hash"]] + \ + [m["id"] for m in c["marks"] if m["id"] == fid] + c = map_page(b, base, son) + click_canvas(b, x, y, move_only=True) + c = map_now(b) + if c["state"] != "drawn": + return [f"the son's map: {c['state']}"] + if leaks(c): + bad.append(f"closed: the map shows {leaks(c)}") + if not any("1 result closed" in t for t in c["chips"]): + bad.append(f"closed: no chip counting it: {c['chips']}") + b.eval("document.querySelector('.gm-chip[data-closed=act] button').click(); true") + c = map_now(b) + if not b.eval("!!document.querySelector('.gm-chip[data-closed=act] .gm-confirm')") or leaks(c): + bad.append("Open: no confirm step, or it opened at once") + b.eval("document.querySelector('.gm-chip[data-closed=act] .gm-yes').click(); true") + c = map_now(b) + card = b.eval("!!document.querySelector('#f-%s h3') && !document.querySelector('#f-%s .veil')" % (fid, fid)) + if [m["id"] for m in c["marks"] if m["id"] == fid] != [fid] or not card: + bad.append(f"Open: mark {[m['id'] for m in c['marks']]}, card shown {card}") + if not any(t.startswith("Opened: " + f["title"][i]) for t in c["chips"]): + bad.append(f"Open: chips {c['chips']}") + if fid in c["hash"] or gene in c["hash"]: + bad.append(f"Open: the address names it: {c['hash']}") + click_canvas(b, x, y, move_only=True) + c = map_now(b) + if gene not in c["tip"]: + bad.append(f"Open: its tooltip at its place does not name it: {c['tip']!r}") + b.eval("document.querySelector('.gm-chip.opened button').click(); true") + c = map_now(b) + card = b.eval("!!document.querySelector('#f-%s .veil')" % fid) + if leaks(c) or not card: + bad.append(f"Close it again: map shows {leaks(c)}, card closed {card}") + b.eval("document.querySelector('#f-%s .veil button').click(); true" % fid) + c = map_now(b) + if [m["id"] for m in c["marks"] if m["id"] == fid] != [fid]: + bad.append("the card's Show the result does not open it on the map") + c = map_page(b, base, son) + if leaks(c): + bad.append(f"a reload does not close it: {leaks(c)}") + # APOE on an adult's page. + c = map_page(b, base, S) + if any(m["id"] == "apoe" for m in c["marks"]) or not any(t.startswith("APOE closed") for t in c["chips"]): + bad.append(f"APOE on DEMO-001: marks {[m['id'] for m in c['marks']]}, chips {c['chips']}") + b.eval("document.querySelector('.gm-chip[data-closed=gate] button').click(); true") + time.sleep(0.3) + b.eval("document.querySelector('.gm-chip[data-closed=gate] .gm-yes').click(); true") + c = map_now(b) + fold = b.eval("(document.querySelector('details[data-gate=apoe]') || {}).open") + if not any(m["id"] == "apoe" for m in c["marks"]) or fold is not True: + bad.append(f"Open APOE: marks {[m['id'] for m in c['marks']]}, its card's fold open {fold}") + b.eval("document.querySelector('details[data-gate=apoe] summary').click(); true") + c = map_now(b) + if any(m["id"] == "apoe" for m in c["marks"]): + bad.append("closing APOE's card fold leaves it on the map") + b.eval("document.querySelector('details[data-gate=apoe] summary').click(); true") + c = map_now(b) + if not any(m["id"] == "apoe" for m in c["marks"]): + bad.append("opening APOE's card fold does not open it on the map") + c = map_page(b, base, S) + if any(m["id"] == "apoe" for m in c["marks"]): + bad.append("a reload does not close APOE") + return bad + + +def missing_layer_problems(b, base, out): + """A map whose ROH layer is missing says so in words, in the margin, never a blank.""" + path = os.path.join(out, "runs", S, "map.json") + with open(path, "rb") as fh: + keep = fh.read() + try: + doc = read_map(path) + doc["layers"]["roh"] = dict(doc["layers"]["roh"], state="missing", count=0, note="planted: no ROH text") + doc["per"][S].pop("roh", None) + write_map(path, doc) + c = map_page(b, base, S, "#chr1") + row = [x for x in c["layers"] if x[0] == "roh"] + if not row or row[0][1] != "missing" or "Not on the map: planted: no ROH text" not in row[0][2]: + return [f"the missing ROH layer reads {row}"] + return [] + finally: + with open(path, "wb") as fh: + fh.write(keep) + + +def fallback_problems(b, base, out): + """A browser with no DecompressionStream: the sentence and the findings with a place, each through the veil.""" + son = OTHERS[1] + doc = read_map(os.path.join(out, "runs", son, "map.json")) + f = doc["per"][son]["findings"] + gene = [f["gene"][i] for i, lv in enumerate(f["level"]) if lv == "act"][0] + b.call("Page.enable", session=b.session) # a script for each new document needs the Page domain on + sid = b.call("Page.addScriptToEvaluateOnNewDocument", {"source": "delete window.DecompressionStream;"}, b.session)["identifier"] + try: + c = map_page(b, base, son) + rows = b.eval("document.querySelectorAll('.gm-fallback table tbody tr').length") + finally: + b.call("Page.removeScriptToEvaluateOnNewDocument", {"identifier": sid}, b.session) + bad = [] + if c["state"] != "nodecompress" or "This browser cannot open the map file" not in c["text"]: + bad.append(f"no DecompressionStream: {c['state']}") + if not rows: + bad.append("no DecompressionStream: no findings table") + if gene in c["text"]: + bad.append(f"no DecompressionStream: the table names the closed {gene}") + return bad + + +def map_screens(b, base, dest): + """Screenshots of the map, dark, at 1440 x 900 and 390 x 844: genome and chromosome, a closed page.""" + os.makedirs(dest, exist_ok=True) + taken = [] + try: + for w, h in ((1440, 900), (390, 844)): + b.call("Emulation.setDeviceMetricsOverride", {"width": w, "height": h, "deviceScaleFactor": 1, "mobile": False}, + b.session) + for run, link, name in ((S, "", "genome"), (S, "#chr16", "chromosome"), (OTHERS[1], "", "closed")): + map_page(b, base, run, link) + b.eval("document.getElementById('genome-map').scrollIntoView({block: 'start'}); true") + time.sleep(0.5) + png = b.call("Page.captureScreenshot", {"format": "png"}, b.session)["data"] + p = os.path.join(dest, f"map-{name}-{w}x{h}.png") + with open(p, "wb") as fh: + fh.write(base64.b64decode(png)) + taken.append(p) + finally: + b.call("Emulation.clearDeviceMetricsOverride", {}, b.session) + return taken + + +def map_checks(b, base, out): + """Figure 1, the genome map: drawn from map.json with every mark the karyogram drew, in time; its levels, + links and a place it cannot read; the veil and opening on purpose; a missing layer; no DecompressionStream; + no sideways scroll at 390 px.""" + if not os.path.isfile(os.path.join(out, "runs", S, "map.json")): + ci = os.environ.get("CI") == "true" + print(("[FAIL] " if ci else "[SKIP] ") + "the demo has no map.json: no og to build it") + if ci: + FAILS.append("no map.json in CI") + return + b.call("Network.enable", session=b.session) # the controls below change files: never serve them from cache + b.call("Network.setCacheDisabled", {"cacheDisabled": True}, b.session) + # A desktop window, so the whole canvas fits in view for the clicks. + b.call("Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 900, "deviceScaleFactor": 1, "mobile": False}, + b.session) + rows = karyogram_marks() + bad = parity_problems(b, base, rows) + check(f"map: the DEMO-001 to DEMO-003 person pages draw the map from map.json and every karyogram mark " + f"({len(rows)} marks: same level and chromosome, within 0.01 Mb)", not bad and len(rows) >= 9, bad) + path = os.path.join(out, "runs", S, "map.json") + with open(path, "rb") as fh: + keep = fh.read() + try: + doc = read_map(path) + f = doc["per"][S]["findings"] + drop = f["id"].index(next(r[1] for r in rows if r[0] == S)) + for k in f: + f[k] = [v for j, v in enumerate(f[k]) if j != drop] + write_map(path, doc) + control("a map.json without one of the karyogram's findings", not parity_problems(b, base, rows)) + finally: + with open(path, "wb") as fh: + fh.write(keep) + g = map_page(b, base, S) + check(f"map: first paint within 150 ms of the document arriving, parsed within 30 ms ({g.get('paint')} ms, " + f"{g.get('parse')} ms)", g.get("paint", 999) <= 150 and g.get("parse", 999) <= 30, g) + bad = state_problems(b, base) + check("map: genome and chromosome levels; #chr16, #chr16:10.0-40.0M, chr=11 and a 1-based span open their view; " + "a click on a row opens it, Esc closes it; an unknown place gives a toast", not bad, bad) + js = os.path.join(out, "assets", "person.js") + with open(js) as fh: + keep_js = fh.read() + bad = map_veil_problems(b, base, out) + check("map: the son's closed result is in no mark, tooltip, chip, sentence, link or address until opened; Open " + "(after its confirm) and the card's Show the result each bring back exactly its mark there and in the card; " + "Close it again and a reload close it; APOE stays closed on an adult's page until opened, from the chip " + "or its card's fold", not bad, bad) + try: + with open(js, "w") as fh: + fh.write(keep_js.replace("function mapVeil(id, gated) {", "function mapVeil(id, gated) { return 'open';")) + control("person.js whose map veil keeps nothing closed", not map_veil_problems(b, base, out)) + finally: + with open(js, "w") as fh: + fh.write(keep_js) + bad = missing_layer_problems(b, base, out) + check("map: a missing layer says why in words, never a blank", not bad, bad) + bad = fallback_problems(b, base, out) + check("map: a browser with no DecompressionStream gets the sentence and a findings table through the veil", not bad, bad) + b.call("Emulation.setDeviceMetricsOverride", {"width": 390, "height": 844, "deviceScaleFactor": 2, "mobile": False}, b.session) + try: + wide = {} + for link in ("", "#chr16"): + map_page(b, base, S, link) + wide[link or "genome"] = too_wide(b) + finally: + b.call("Emulation.clearDeviceMetricsOverride", {}, b.session) + check("map: at 390 px the map, open or not, scrolls nothing sideways", not any(wide.values()), wide) + if os.environ.get("MAP_SCREENS"): + print("map screenshots: " + ", ".join(map_screens(b, base, os.environ["MAP_SCREENS"]))) + + +def screens_main(dest, site): + """--map-screens DEST SITE: the map's screenshots of an already built demo (the docs workflow's artifact).""" + chrome = find_chrome() + if not chrome: + print("ERROR: no Chrome or Chromium to take the screenshots") + return 1 + handler = type("H", (Static,), {"__init__": lambda self, *a, **k: SimpleHTTPRequestHandler.__init__( + self, *a, directory=site, **k)}) + srv = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + work = tempfile.mkdtemp(prefix="map-screens.") + b = Chrome(chrome, os.path.join(work, "chrome")) + try: + taken = map_screens(b, f"http://127.0.0.1:{srv.server_address[1]}", dest) + finally: + b.close() + srv.shutdown() + srv.server_close() + shutil.rmtree(work, ignore_errors=True) + print("\n".join(taken)) + return 0 if len(taken) == 6 else 1 + + def main(): work = tempfile.mkdtemp(prefix="test_live_demo.") try: @@ -1188,7 +1598,8 @@ def main(): control("a fixture missing its last message", make_demo_events.main(["--check", "--out", changed]) == 0) out = os.path.join(work, "demo") - built = build_live_demo.build(out, work=os.path.join(work, "root")) + og = og_binary() or build_live_demo.OG + built = build_live_demo.build(out, work=os.path.join(work, "root"), og=og) frames = built["frames"] print(f"built: {len(frames)} frames, hooks {built['hooks']}") @@ -1267,4 +1678,6 @@ def main(): if __name__ == "__main__": + if sys.argv[1:2] == ["--map-screens"] and len(sys.argv) == 4: + sys.exit(screens_main(sys.argv[2], sys.argv[3])) sys.exit(main()) diff --git a/tests/test_live_results.py b/tests/test_live_results.py index 8213596a2..3e304b9aa 100644 --- a/tests/test_live_results.py +++ b/tests/test_live_results.py @@ -10,9 +10,12 @@ its SECTION_NOTES constants, word for word, never empty; 3. the wording gate: no string of results.json, of family.json (tests/fixtures/families_live family_a) or of the page assets - (assets/live: the text of the pages, the string literals of the scripts) - says a result is fine (live_results.FORBIDDEN), a quoted tool term and a - caveat aside; + (assets/live: the text of the pages, the string literals of the scripts, + map.js among them) says a result is fine (live_results.FORBIDDEN), a quoted + tool term and a caveat aside; nor does any string of the genome map + documents `og map build` writes for DEMO-001 and DEMO-003 (Figure 1 shows + their sentences), a layer's state code aside (skipped without og or + cargo, except in CI); 4. ClinVar: with hits the card lists them in a table; with none it has no table and its empty text says ClinVar lists only part of what causes disease (never an all-clear); @@ -145,6 +148,32 @@ def asset_problems(texts): return out +def map_documents(root, people): + """[(run, document)] of `og map build` for each (run, results) of PEOPLE, as the map plugin builds them; + None without an og (tests/test_live_demo.py og_binary).""" + import gzip + from test_live_demo import og_binary + og = og_binary() + if not og: + return None + ref = os.path.join(root, "reference") + if not os.path.isdir(ref): + shutil.copytree(os.path.join(REPO, "tests", "fixtures", "map", "gencode-slice", "reference"), ref) + out = [] + for run, results in people: + body = {k: v for k, v in results.items() if not k.startswith("_")} + p = subprocess.run([og, "map", "build", "--root", root, "--run", run, "--assets", os.path.join(REPO, "assets"), + "--cache-dir", os.path.join(root, ".map-cache")], input=json.dumps({"results": body}).encode(), + capture_output=True, check=True) + out.append((run, json.loads(gzip.decompress(p.stdout)))) + return out + + +def map_wording(doc): + """The strings of a map document that read as an all-clear; a layer's state is a code, not words.""" + return live_results.wording_problems(doc, allow=("state",)) + + # --- 4. ClinVar with and without hits ------------------------------------------------------- def clinvar_card(summary): @@ -534,6 +563,21 @@ def tagged(sample_dir): finally: live_results.veil_id = keep + # 3, the map documents: og map build of DEMO-001 and DEMO-003, with the results each page gets. + map_docs = map_documents(root, [(SAMPLE, res), ("DEMO-003", tagged(d3)[0])]) + if map_docs is None: + ci = os.environ.get("CI") == "true" + print(("[FAIL] " if ci else "[SKIP] ") + "3. no og (and no cargo to build one) for the map documents") + if ci: + FAILS.append("no og in CI") + else: + bad = [(run, x) for run, doc in map_docs for x in map_wording(doc)] + check(f"3. no string of the genome map documents reads as an all-clear ({len(map_docs)} documents)", + not bad and len(map_docs) == 2, bad) + planted = copy.deepcopy(map_docs[0][1]) + planted["text"]["genome"] = [{"t": "All clear on the map.", "b": False}] + control("'All clear' planted in a map document's sentence", not map_wording(planted)) + # 5. pwfile = os.path.join(work, "password") with open(pwfile, "w") as f: From 2e3e9d85fa2cb5a31b8d5acb4ba059f693f7cbbc Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:28:26 +0100 Subject: [PATCH 09/27] feat(live): replace the findings karyogram with a genome map Figure 1 of a person page now draws the og map document: every chromosome at genome level, and one chromosome opened in place down to about 10 Mb, with the findings lane, runs of homozygosity and the bands. Closed results stay off the map until opened on purpose, with the same switch as the card. Without the document the page shows a table. Bead: pgp-iqon.8 --- assets/live/core.js | 70 +-- assets/live/index.html | 2 + assets/live/live.css | 6 - assets/live/map.css | 57 +++ assets/live/map.js | 1074 ++++++++++++++++++++++++++++++++++++++++ assets/live/person.js | 67 ++- 6 files changed, 1186 insertions(+), 90 deletions(-) create mode 100644 assets/live/map.css create mode 100644 assets/live/map.js diff --git a/assets/live/core.js b/assets/live/core.js index b6e4ff071..0d1db0fe9 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, 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, the table, and the Journal's level marks and chromosome map. Text only through textContent and createElementNS: +// caveat block, the facts list, the table, and the Journal's level marks. Text only through textContent and createElementNS: // no innerHTML, no inline script, no eval. Everything hangs on window.PGPUI. (function () { 'use strict'; @@ -124,7 +124,7 @@ }); } - // --- the Journal's shared marks: the level glyph and word, and the chromosome map of findings --- + // --- the Journal's shared marks: the level glyph and word --- 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' }; @@ -137,68 +137,6 @@ } // 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], - ['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) : ''; } @@ -209,7 +147,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, findingLevel: findingLevel, glyph: glyph, - karyogram: karyogram, locus: locus, LEVEL_WORDS: LEVEL_WORDS, LEVEL_SHORT: LEVEL_SHORT, LEVEL_ORDER: LEVEL_ORDER, + facts: facts, table: table, tablist: tablist, firstSentence: firstSentence, level: level, findingLevel: findingLevel, + LEVEL_WORDS: LEVEL_WORDS, LEVEL_SHORT: LEVEL_SHORT, LEVEL_ORDER: LEVEL_ORDER, hm: hm, dm: dm, hours: hours }; }()); diff --git a/assets/live/index.html b/assets/live/index.html index b272cd44f..d740c1eda 100644 --- a/assets/live/index.html +++ b/assets/live/index.html @@ -5,8 +5,10 @@ {{run}}: OwnGenome + + {{plugins}} diff --git a/assets/live/live.css b/assets/live/live.css index 5dba9f176..6c0959048 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -321,12 +321,6 @@ ol.w-path li.w-low { border-style: dashed; border-color: var(--car); } .w-count { color: var(--ink-3); font-size: 12px; } /* 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 14px; } figure.chart h3 { font: 600 10.5px var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 8px; } diff --git a/assets/live/map.css b/assets/live/map.css new file mode 100644 index 000000000..cc3e42e00 --- /dev/null +++ b/assets/live/map.css @@ -0,0 +1,57 @@ +/* map.css: Figure 1 of a person's page, the genome map (assets/live/map.js). Dark only, the Journal tokens of + live.css. Brass marks where you are, never a level; the levels' colours mark findings only. */ +.gmap { margin-top: 34px; scroll-margin-top: 72px; } +.gm-main { min-width: 0; } +.gm-head { display: grid; gap: 6px; margin-bottom: 10px; } +.gm-titlerow { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.gm-title { font: 400 27px/1.15 var(--serif); color: var(--ink); } +.gm-gauge { display: inline-flex; border: 1px solid var(--hair-2); border-radius: 999px; overflow: hidden; } +.gm-gauge button { border: 0; border-radius: 0; background: transparent; color: var(--ink-3); padding: 6px 12px; font-size: 12px; } +.gm-gauge button[aria-pressed="true"] { color: var(--page); background: var(--brass); } +.gm-crumb { color: var(--ink-3); font-size: 12px; } +.gm-crumb button { background: none; border: 0; padding: 0; color: var(--ink-2); font: inherit; text-decoration: underline; + text-decoration-color: var(--brass-dim); text-underline-offset: 3px; } +.gm-crumb [aria-current] { color: var(--ink); } +.gm-chips { display: flex; flex-wrap: wrap; gap: 8px; } +.gm-chips:empty { display: none; } +.gm-chip { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; font: 12.5px/1.4 var(--sans); color: var(--ink-2); + border: 1px dashed var(--hair-2); border-radius: 8px; padding: 6px 10px; max-width: 100%; } +.gm-chip.opened { border-style: solid; border-color: var(--brass-dim); } +.gm-chip button { padding: 5px 10px; font-size: 12px; } +.gm-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--ink); } +.gm-stage { position: relative; } +.gm-canvas { display: block; width: 100%; touch-action: pan-y; border-radius: 6px; } +.gm-canvas:focus-visible { outline-offset: 4px; } +.gm-tip { position: absolute; z-index: 5; max-width: 260px; pointer-events: none; background: var(--paper-2); border: 1px solid var(--hair-2); + border-radius: 6px; padding: 7px 9px; font: 12px/1.4 var(--sans); color: var(--ink-2); display: grid; gap: 2px; } +.gm-tip b { color: var(--ink); font-weight: 500; } +.gm-toast { position: absolute; left: 50%; top: 8px; transform: translateX(-50%); background: var(--paper-2); border: 1px solid var(--brass-dim); + border-radius: 6px; padding: 7px 12px; font: 12.5px/1.4 var(--sans); color: var(--ink); max-width: 90%; } +/* The marks as links: the path for the keyboard and a screen reader, out of sight until focused. */ +.gm-marks { list-style: none; margin: 0; padding: 0; position: relative; } +.gm-marks li { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +.gm-marks li:focus-within { position: static; width: auto; height: auto; clip-path: none; white-space: normal; font-size: 12.5px; margin-top: 6px; } +.gm-fallback .gm-fail { color: var(--ink-2); } +.gm-margin { font-size: 13px; } +.gm-mline { color: var(--ink-3); font-size: 12px; margin: 6px 0 4px; } +.gm-narration { font: 400 15.5px/1.5 var(--serif); color: var(--ink-2); margin-top: 8px; } +.gm-narration b { color: var(--ink); font-weight: 600; } +.gm-narration p { margin: 0 0 10px; } +.gm-narration .dim { font: 12.5px/1.45 var(--sans); } +.gm-more { margin-top: 12px; } +.gm-more > summary { font: 12.5px var(--sans); color: var(--ink-3); cursor: pointer; } +.gm-layers { margin: 10px 0 0; display: grid; gap: 8px; } +.gm-layer dt { font: 600 10.5px/1.3 var(--sans); letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); } +.gm-layer dd { margin: 2px 0 0; color: var(--ink-2); font-size: 12.5px; line-height: 1.45; } +.gm-layer[data-state="missing"] dd, .gm-layer[data-state="unreadable"] dd { color: var(--ink); } +.gm-caveat summary { color: var(--ink-3); cursor: pointer; font-size: 12px; } +.gm-key { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 14px; font-size: 12px; color: var(--ink-2); } +.gm-key .fig-label { flex-basis: 100%; } +.gm-k-roh, .gm-k-mask { display: inline-flex; align-items: center; gap: 6px; } +.gm-k-roh .sw { width: 16px; height: 3px; background: var(--ink); border-radius: 2px; } +.gm-k-mask .sw { width: 16px; height: 8px; border-radius: 3px; background: repeating-linear-gradient(135deg, #26272c 0 2px, #4a4b50 2px 3px); } +@media (max-width: 700px) { + .gm-title { font-size: 22px; } + .gm-margin { margin-top: 12px; } + .gm-narration { font-size: 14px; } +} diff --git a/assets/live/map.js b/assets/live/map.js new file mode 100644 index 000000000..94ffbaee0 --- /dev/null +++ b/assets/live/map.js @@ -0,0 +1,1074 @@ +// map.js: Figure 1 of a person's page, the genome map. It draws the map document the server builds with +// `og map build` (GET map.json beside the page: gzip-wrapped columnar JSON, rust/crates/og-map/README.md): +// the 24 chromosomes as rows with their bands, the masked spans hatched, each finding with a place as its +// level's glyph, and the runs of homozygosity; a click opens a chromosome in place, where the bands, the +// findings and the runs get lanes of their own under a ruler. Every mark comes from the document, never from +// results.json, and every sentence the margin shows is one the document wrote (text, phrase, empty, note), +// with the counts filled in here. +// +// The veil. The document is the same for every reader: each row a closed result could reveal carries the id +// of that result (veil). Whether it is closed is the page's question (person.js veil()): a closed row is not +// drawn, has no hit target, no tooltip, no line in the margin and never reaches the address. The closed +// results are counted in a dashed chip that names no gene and no place, with an Open button that asks once, +// then opens the result through the page's own function, so the map and the card open together. A reload +// closes them again: the open state lives in the page's memory only. +// +// No library and no build step: one canvas, the camera {ci, c, ls, k} (open chromosome, centre bp, natural +// log of the span, 0 genome .. 1 open) eased toward its target each frame, one level() by bp per CSS pixel. +// Text only through textContent: no innerHTML, no style attribute. Everything hangs on window.PGPMap. +(function () { + 'use strict'; + var U = window.PGPUI; + if (!U) { return; } + var el = U.el, add = U.add; + var LV = { act: '#d03b3b', medicine: '#ec835a', review: '#fab219', carrier: '#9085e9', trust: '#c3c2b7', info: '#c3c2b7' }; + var PRI = { act: 0, medicine: 1, review: 2, carrier: 3, trust: 4, info: 5 }; + var STAIN = { gneg: '#26272c', gpos25: '#323337', gpos50: '#3e3f42', gpos75: '#4c4c4d', gpos100: '#5a5955', + gvar: '#25262a', stalk: '#25262a', acen: '#2e2f34' }; + var BRASS = '#c8ab78', INK = '#f0efec', INK2 = '#c3c2b7', INK3 = '#8f8d86', PAGE = '#0e0f10'; + var SANS = 'system-ui, -apple-system, "Segoe UI", sans-serif'; + var MONO = 'ui-monospace, "SF Mono", Menlo, monospace'; + // The chromosome level ends at 16,667 bp per CSS pixel: closer views (region, gene, reads) come later. + var CHROM_BPP = 16667; + // A genome row's least height: 22 CSS px, 14 on a phone (the page there is long enough already). + var ROW_MIN = 22, ROW_PHONE = 14; + var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + function lerp(a, b, t) { return a + (b - a) * t; } + function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } + function mb(bp, d) { return (bp / 1e6).toFixed(d === undefined ? 1 : d); } + function comma(n) { return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } + // A sentence of the document: pieces of text, the bold ones in . + function runs(list) { + var p = el('p', {}); + (list || []).forEach(function (x) { add(p, x.b ? el('b', {}, x.t) : document.createTextNode(x.t)); }); + return p; + } + function short(n) { return String(n).replace(/^chr/, ''); } + // A layer's states that put nothing on the map (the document's layer entry); any other state has landed. + var NOT_ON_MAP = { missing: 1, unreadable: 1, not_run: 1, not_in_this_run: 1, running: 1, waiting: 1, failed: 1, skipped: 1 }; + function landed(lay) { return !!lay && !NOT_ON_MAP[lay.state]; } + + // --- the document: gzip -> JSON -> typed columns ------------------------------------------------------ + // Starts are gaps from the previous row (ds) and ends are lengths (l): s, e and the running maximum of e + // are rebuilt here in one pass, so a view [a, b) is a binary search on that maximum. + function columns(o) { + var n = (o.ds || []).length, s = new Float64Array(n), e = new Float64Array(n), me = new Float64Array(n); + var at = 0, m = 0; + for (var i = 0; i < n; i++) { + at += o.ds[i]; + s[i] = at; + e[i] = at + o.l[i]; + m = Math.max(m, e[i]); + me[i] = m; + } + return { n: n, s: s, e: e, me: me, o: o }; + } + // The first row whose running maximum end passes a: every row from there with s < b meets [a, b). + function first(c, a) { + var lo = 0, hi = c.n; + while (lo < hi) { var mid = (lo + hi) >> 1; if (c.me[mid] > a) { hi = mid; } else { lo = mid + 1; } } + return lo; + } + function prepare(d, who) { + var person = (d.people || [])[0] || {}; + var id = who || person.id; + var per = (d.per || {})[id] || {}; + var chroms = ((d.ref && d.ref.chroms) || []).filter(function (c) { return !(c.n === 'chrY' && person.has_y === false); }); + var by = {}; + chroms.forEach(function (c, i) { by[c.n] = i; }); + var bands = {}, roh = {}, bins = {}; + chroms.forEach(function (c) { + var b = d.ref.bands && d.ref.bands[c.n]; + if (b) { bands[c.n] = columns(b); } + if (per.roh && per.roh[c.n]) { roh[c.n] = columns(per.roh[c.n]); } + if (per.roh_bins && per.roh_bins[c.n]) { bins[c.n] = new Float64Array(per.roh_bins[c.n]); } + }); + var f = per.findings || {}, veilIds = (d.dict && d.dict.veil) || []; + var rows = []; + (f.id || []).forEach(function (fid, i) { + if (!(f.chrom[i] in by)) { return; } + rows.push({ id: fid, level: f.level[i], gate: f.gate[i], chrom: f.chrom[i], pos: f.pos[i], s: f.s[i], e: f.e[i], + gene: f.gene[i], title: f.title[i], href: f.href[i], phrase: f.phrase[i], veil: f.veil[i] >= 0 ? veilIds[f.veil[i]] : null }); + }); + var gated = {}; + rows.forEach(function (r) { if (r.gate) { gated[r.id] = r; } }); + return { doc: d, id: id, label: person.label || id, chroms: chroms, by: by, bands: bands, roh: roh, bins: bins, + binSize: (per.roh_bins && per.roh_bins.size) || 1e6, findings: rows, gated: gated, veilIds: veilIds, + layers: d.layers || {}, text: d.text || {}, art: (d.dict && d.dict.art) || [] }; + } + + // --- the figure --------------------------------------------------------------------------------------- + // create({url, veil(id, gated) -> 'open'|'closed'|'opened', now(id) -> shown at this moment, open(id), + // close(id), fallback() -> rows}): one figure per page. The page calls refresh(key) on each of its redraws; a new key + // fetches the document again and keeps the camera. + function create(opts) { + var M = null, key = null, failure = null, t0 = 0; + var fig = el('figure', { 'class': 'row fig1 gmap', id: 'genome-map', 'aria-labelledby': 'gm-title' }); + var main = el('div', { 'class': 'gm-main' }); + var head = el('div', { 'class': 'gm-head' }); + var kicker = el('div', { 'class': 'fig-label' }, 'Figure 1 · Genome map'); + var title = el('h2', { 'class': 'gm-title', id: 'gm-title' }, 'The genome map'); + var gauge = el('div', { 'class': 'gm-gauge', role: 'group', 'aria-label': 'Zoom level' }); + var stopG = el('button', { type: 'button', 'data-stop': 'genome' }, 'Genome'); + var stopC = el('button', { type: 'button', 'data-stop': 'chromosome' }, 'Chromosome'); + add(gauge, stopG, stopC); + var crumb = el('nav', { 'class': 'gm-crumb mono', 'aria-label': 'Where the map is' }); + var chips = el('div', { 'class': 'gm-chips' }); + add(head, kicker, add(el('div', { 'class': 'gm-titlerow' }), title, gauge), crumb, chips); + var stage = el('div', { 'class': 'gm-stage' }); + var canvas = el('canvas', { 'class': 'gm-canvas', tabindex: '0', role: 'img', 'aria-describedby': 'gm-narration' }); + var tip = el('div', { 'class': 'gm-tip', role: 'tooltip', hidden: 'hidden' }); + var toast = el('div', { 'class': 'gm-toast', role: 'status', hidden: 'hidden' }); + add(stage, canvas, tip, toast); + // The marks drawn, as links: the keyboard and screen-reader path to each finding (Tab steps through them). + var marks = el('ul', { 'class': 'gm-marks', 'aria-label': 'Findings on the map' }); + var fallback = el('div', { 'class': 'gm-fallback', hidden: 'hidden' }); + add(main, head, stage, marks, fallback); + var margin = el('aside', { 'class': 'gm-margin' }); + var mkick = el('div', { 'class': 'fig-label' }, 'The whole genome'); + var mline = el('div', { 'class': 'gm-mline mono' }); + var narr = el('div', { 'class': 'gm-narration', id: 'gm-narration', 'aria-live': 'polite' }); + var layersBox = el('dl', { 'class': 'gm-layers' }); + var keyBox = el('div', { 'class': 'gm-key', 'aria-label': 'Key' }); + // The layers and the key: open beside the map, folded under it on a phone. + var more = el('details', { 'class': 'gm-more' }); + more.open = !(window.matchMedia && window.matchMedia('(max-width: 700px)').matches); + add(more, el('summary', {}, 'Layers and key'), layersBox, keyBox); + add(margin, mkick, mline, narr, more); + add(fig, main, margin); + + var ctx = canvas.getContext('2d'); + var CW = 0, CH = 0, DPR = 1, phone = false; + var cam = { ci: 0, c: 0, ls: 0, k: 0 }, tgt = { ci: 0, c: 0, ls: 0, k: 0 }; + var hover = { row: -1, hit: null }, hits = [], raf = 0, settleT = 0, hashT = 0, confirming = null, touched = false; + var cache = null, cacheKey = ''; + var hatch = null; + + // --- the veil: what this page keeps closed at this moment --- + function stateOf(id) { return opts.veil(id, !!(M && M.gated[id])); } + // A row shows when its result is not closed and the page shows its finding at this moment (a replay + // before the finding's step ended does not). + function shownRow(r) { return (!r.veil || stateOf(r.veil) !== 'closed') && (!opts.now || opts.now(r.id)); } + function drawn() { return M ? M.findings.filter(shownRow) : []; } + + // --- geometry --- + function chrom() { return M.chroms[cam.ci]; } + function rowH() { var lo = phone ? ROW_PHONE : ROW_MIN; return M ? clamp((CH - 18) / M.chroms.length, lo, 44) : lo; } + function gutG() { return phone ? 30 : 40; } + function pad() { return phone ? 10 : 22; } + function gut() { return lerp(gutG(), phone ? 64 : 128, cam.k); } + function plotW() { return CW - gut() - pad(); } + function minSpan(len) { return Math.min(len, CHROM_BPP * Math.max(200, CW - (phone ? 64 : 128) - pad())); } + function view() { + var span = Math.exp(cam.ls); + return [cam.c - span / 2, cam.c + span / 2, span]; + } + function gx(bp) { return gutG() + bp * (CW - gutG() - pad()) / M.chroms[0].len; } + function ox(bp) { var v = view(); return gut() + (bp - v[0]) / v[2] * plotW(); } + function xAt(bp, open) { return open ? lerp(gx(bp), ox(bp), cam.k) : gx(bp); } + function rowY(i) { return 9 + i * rowH() + rowH() / 2; } + function level() { + if (cam.k < 0.5) { return 'genome'; } + return 'chromosome'; + } + // The lanes of an open chromosome, top to bottom: y of each, stretched to the canvas. + function lanes() { + var free = Math.max(0, CH - 230), st = 1 + Math.min(0.75, free / 400); + var fy = 118, fh = 50 * Math.min(1.2, st), ry = fy + fh + 14, rh = 22 * st; + return { over: 10, ruler: 46, bands: 72, bh: 38, fy: fy, fh: fh, ry: ry, rh: rh }; + } + + // --- the canvas size, the hatch pattern --- + function size() { + var w = Math.max(280, Math.floor(stage.clientWidth || 680)); + phone = w < 520; + var n = M ? M.chroms.length : 24; + var h = 18 + n * (phone ? ROW_PHONE : ROW_MIN); + DPR = Math.min(2, window.devicePixelRatio || 1); + if (w !== CW || h !== CH) { + CW = w; CH = h; + canvas.width = Math.round(w * DPR); + canvas.height = Math.round(h * DPR); + canvas.style.setProperty('width', w + 'px'); + canvas.style.setProperty('height', h + 'px'); + cacheKey = ''; + } + } + function pattern(alpha) { + var p = document.createElement('canvas'); + p.width = p.height = 6; + var c = p.getContext('2d'); + c.strokeStyle = 'rgba(255,255,255,' + alpha + ')'; + c.lineWidth = 1; + c.beginPath(); c.moveTo(-1, 7); c.lineTo(7, -1); c.moveTo(5, 7); c.lineTo(7, 5); c.moveTo(-1, 1); c.lineTo(1, -1); c.stroke(); + return ctx.createPattern(p, 'repeat'); + } + + // --- drawing --- + function rounded(c, x, y, w, h, r) { + r = Math.max(0, Math.min(r, w / 2, h / 2)); + c.moveTo(x + r, y); c.lineTo(x + w - r, y); c.arcTo(x + w, y, x + w, y + r, r); c.lineTo(x + w, y + h - r); + c.arcTo(x + w, y + h, x + w - r, y + h, r); c.lineTo(x + r, y + h); c.arcTo(x, y + h, x, y + h - r, r); + c.lineTo(x, y + r); c.arcTo(x, y, x + r, y, r); + } + // One chromosome's body: two rounded arms pinched at the centromere, filled with its bands, the masked + // spans hatched. X maps a bp to a canvas x. + function body(c, ch, X, yc, h, alpha) { + var cen = ch.cen ? (ch.cen[0] + ch.cen[1]) / 2 : null; + c.save(); + c.globalAlpha = alpha; + c.beginPath(); + var x0 = X(0), x1 = X(ch.len), xm = cen === null ? null : X(cen); + if (xm === null) { rounded(c, x0, yc - h / 2, x1 - x0, h, h / 2); } else { + rounded(c, x0, yc - h / 2, Math.max(1, xm - x0 - 0.6), h, h / 2); + rounded(c, xm + 0.6, yc - h / 2, Math.max(1, x1 - xm - 0.6), h, h / 2); + } + c.clip(); + c.fillStyle = STAIN.gneg; + c.fillRect(x0, yc - h / 2, x1 - x0, h); + var b = M.bands[ch.n]; + if (b) { + for (var i = 0; i < b.n; i++) { + var st = b.o.stain[i]; + if (st === 'gneg') { continue; } + var a = X(b.s[i]), z = X(b.e[i]); + if (z < 0 || a > CW) { continue; } + c.fillStyle = STAIN[st] || STAIN.gneg; + c.fillRect(a, yc - h / 2, Math.max(0.5, z - a), h); + } + } + c.fillStyle = hatch; + (ch.masked || []).forEach(function (m) { var a = X(m[0]), z = X(m[1]); c.fillRect(a, yc - h / 2, Math.max(1, z - a), h); }); + c.restore(); + } + function glyph(c, lv, x, y, r) { + c.save(); + c.fillStyle = LV[lv] || LV.info; + c.strokeStyle = PAGE; + c.lineWidth = 3; + c.beginPath(); + if (lv === 'act') { c.moveTo(x, y - r); c.lineTo(x + r, y); c.lineTo(x, y + r); c.lineTo(x - r, y); c.closePath(); c.stroke(); c.fill(); } + else if (lv === 'medicine') { rounded(c, x - r * 1.3, y - r * 0.7, r * 2.6, r * 1.4, r * 0.7); c.stroke(); c.fill(); } + else if (lv === 'review') { c.moveTo(x, y - r); c.lineTo(x + r, y + r * 0.8); c.lineTo(x - r, y + r * 0.8); c.closePath(); c.stroke(); c.fill(); } + else { + var two = lv === 'carrier', rr = two ? r * 0.75 : r * 0.9; + var at = two ? [x - rr * 0.75, x + rr * 0.75] : [x]; + at.forEach(function (cx) { c.moveTo(cx + rr, y); c.arc(cx, y, rr, 0, Math.PI * 2); }); + c.stroke(); + c.strokeStyle = LV[lv] || LV.info; + c.lineWidth = 1.5; + c.beginPath(); + at.forEach(function (cx) { c.moveTo(cx + rr, y); c.arc(cx, y, rr, 0, Math.PI * 2); }); + c.stroke(); + } + c.restore(); + } + function label(c, text, x, y, font, colour) { + c.font = font; + c.lineJoin = 'round'; + c.strokeStyle = PAGE; + c.lineWidth = 4; + c.strokeText(text, x, y); + c.fillStyle = colour; + c.fillText(text, x, y); + } + var widths = {}; + function measure(font, text) { + var k = font + '\u0000' + text; + if (!(k in widths)) { ctx.font = font; widths[k] = ctx.measureText(text).width; } + return widths[k]; + } + + // The genome rows, drawn once into an offscreen canvas until the data, the open results or the size change. + function genomeRows() { + var k = [CW, CH, DPR, drawn().map(function (r) { return r.id; }).join(',')].join('|'); + if (cache && k === cacheKey) { return cache; } + cache = cache || document.createElement('canvas'); + cache.width = canvas.width; + cache.height = canvas.height; + var c = cache.getContext('2d'); + c.setTransform(DPR, 0, 0, DPR, 0, 0); + c.clearRect(0, 0, CW, CH); + var fs = drawn(); + var occupied = {}; + M.chroms.forEach(function (ch, i) { + var yc = rowY(i); + c.fillStyle = INK3; + c.font = '11px ' + SANS; + c.textAlign = 'right'; + c.textBaseline = 'middle'; + c.fillText(short(ch.n), gutG() - 10, yc); + c.textAlign = 'left'; + body(c, ch, gx, yc, 8, 1); + // Runs of homozygosity under the body: the long ones solid, the rest as 1 Mb coverage bins. + var bins = M.bins[ch.n], ry = yc + 6.5; + if (bins) { + c.fillStyle = INK2; + for (var b = 0; b < bins.length; b++) { + if (!bins[b]) { continue; } + c.globalAlpha = 0.22 * Math.min(1, bins[b] / M.binSize); + c.fillRect(gx(b * M.binSize), ry - 1, Math.max(1, gx((b + 1) * M.binSize) - gx(b * M.binSize)), 2); + } + c.globalAlpha = 1; + } + var rr = M.roh[ch.n]; + if (rr) { + c.strokeStyle = INK; + c.lineWidth = 2.5; + for (var j = 0; j < rr.n; j++) { + if (!rr.o.long[j]) { continue; } + c.beginPath(); c.moveTo(gx(rr.s[j]), ry); c.lineTo(gx(rr.e[j]), ry); c.stroke(); + } + } + occupied[i] = []; + }); + // Findings: glyphs pinned to their row, names to the right by level (act first), never over another + // glyph or name of the row: a name that does not fit is left to the margin and the list. + var byLevel = fs.slice().sort(function (a, b) { return PRI[a.level] - PRI[b.level] || a.s - b.s; }); + byLevel.forEach(function (r) { + var i = M.by[r.chrom], yc = rowY(i), x = gx((r.s + r.e) / 2); + if (gx(r.e) - gx(r.s) > 3) { + c.fillStyle = LV[r.level] || LV.info; + c.fillRect(gx(r.s), yc + 4.5, gx(r.e) - gx(r.s), 2); + } + glyph(c, r.level, x, yc - 1, phone ? 3.5 : 4.5); + occupied[i].push([x - 6, x + 6]); + }); + byLevel.forEach(function (r) { + var i = M.by[r.chrom], yc = rowY(i), x = gx((r.s + r.e) / 2); + var name = r.gene || ''; + if (!name) { return; } + var font = '500 10.5px ' + SANS, w = measure(font, name), lx = x + 8; + var clash = occupied[i].some(function (o) { return !(lx + w < o[0] || lx > o[1]) && !(o[0] === x - 6 && o[1] === x + 6); }); + if (lx + w > CW - 2 || clash) { return; } + occupied[i].push([lx - 2, lx + w + 4]); + c.textBaseline = 'middle'; + label(c, name, lx, yc - 1, font, INK2); + }); + cacheKey = k; + return cache; + } + + function draw() { + raf = 0; + if (!M) { return; } + var t = performance.now(); + size(); // the page may have changed width since the last frame (a phone turned, a fold opened) + step(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + hits = []; + if (cam.k < 0.999) { drawGenome(); } + if (cam.k > 0.001) { drawOpen(); } + if (!fig.hasAttribute('data-pgp-map-paint-ms') && t0) { + fig.setAttribute('data-pgp-map-paint-ms', String(Math.round(performance.now() - t0))); + } + if (moving()) { schedule(); } else { settle(); } + return t; + } + function drawGenome() { + var k = cam.k, n = M.chroms.length; + if (k < 0.001) { + if (hover.row >= 0 && level() === 'genome') { + ctx.fillStyle = 'rgba(255,255,255,.035)'; + ctx.fillRect(0, rowY(hover.row) - rowH() / 2, CW, rowH()); + var ch = M.chroms[hover.row], words = 'Open chromosome ' + short(ch.n) + ' ›'; + var font = '500 11px ' + SANS, w = measure(font, words), x = gx(ch.len) + 12; + if (x + w < CW - 4) { ctx.textBaseline = 'middle'; label(ctx, words, x, rowY(hover.row), font, BRASS); } + } + ctx.drawImage(genomeRows(), 0, 0, CW, CH); + M.chroms.forEach(function (c, i) { + hits.push({ kind: 'row', i: i, x0: 0, x1: CW, y0: rowY(i) - rowH() / 2, y1: rowY(i) + rowH() / 2 }); + }); + drawn().forEach(function (r) { + hits.push({ kind: 'finding', r: r, x: gx((r.s + r.e) / 2), y: rowY(M.by[r.chrom]) - 1, rad: 7 }); + }); + return; + } + // The motion: the other rows slide away from the one opening and fade. + var a = Math.pow(1 - k, 1.4); + for (var i = 0; i < n; i++) { + if (i === cam.ci) { continue; } + var dy = k * CH * 0.9 * (0.6 + 0.04 * Math.abs(i - cam.ci)) * (i < cam.ci ? -1 : 1); + ctx.save(); + ctx.globalAlpha = a; + ctx.translate(0, dy); + ctx.fillStyle = INK3; + ctx.font = '11px ' + SANS; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(short(M.chroms[i].n), gutG() - 10, rowY(i)); + ctx.textAlign = 'left'; + body(ctx, M.chroms[i], gx, rowY(i), 8, 1); + ctx.restore(); + } + } + function niceStep(span, px) { + var raw = span / Math.max(1, px / 90), p = Math.pow(10, Math.floor(Math.log10(raw))); + var m = raw / p; + return (m < 1.5 ? 1 : m < 3.5 ? 2 : m < 7.5 ? 5 : 10) * p; + } + function drawOpen() { + var k = cam.k, ch = chrom(), L = lanes(), v = view(), g = gut(), W = plotW(); + var X = function (bp) { return xAt(bp, true); }; + var yb = lerp(rowY(cam.ci), L.bands + L.bh / 2, k), hb = lerp(8, L.bh, k); + ctx.save(); + // The open row's label moves into the gutter. + ctx.fillStyle = INK3; + ctx.font = '11px ' + SANS; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(k > 0.5 ? 'Bands' : short(ch.n), lerp(gutG() - 10, g - 12, k), yb); + ctx.restore(); + ctx.save(); + ctx.beginPath(); + ctx.rect(lerp(0, g - 2, k), 0, CW, CH); + ctx.clip(); + body(ctx, ch, X, yb, hb, 1); + ctx.restore(); + ctx.save(); + ctx.globalAlpha = k; + // Overview: the whole chromosome, the view as a brass window, a funnel to the ruler. + var ow = W, oX = function (bp) { return g + bp / ch.len * ow; }; + body(ctx, ch, oX, L.over + 6, 8, 0.8); + ctx.strokeStyle = BRASS; + ctx.lineWidth = 1.5; + var wa = oX(clamp(v[0], 0, ch.len)), wz = oX(clamp(v[1], 0, ch.len)); + ctx.strokeRect(wa, L.over, Math.max(3, wz - wa), 12); + ctx.globalAlpha = k * 0.35; + ctx.beginPath(); ctx.moveTo(wa, L.over + 12); ctx.lineTo(g, L.ruler); ctx.moveTo(wz, L.over + 12); ctx.lineTo(g + W, L.ruler); ctx.stroke(); + ctx.globalAlpha = k; + // The ruler, in Mb. + ctx.strokeStyle = 'rgba(255,255,255,.14)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(g, L.ruler); ctx.lineTo(g + W, L.ruler); ctx.stroke(); + var stp = niceStep(v[2], W); + ctx.fillStyle = INK3; + ctx.font = '10.5px ' + SANS; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (var at = Math.ceil(v[0] / stp) * stp; at <= v[1]; at += stp) { + var x = ox(at); + ctx.beginPath(); ctx.moveTo(x, L.ruler); ctx.lineTo(x, L.ruler + 5); ctx.stroke(); + ctx.fillText(mb(at, stp < 1e6 ? 1 : 0) + ' Mb', x, L.ruler + 8); + } + // Band names where they fit. + var b = M.bands[ch.n]; + if (b) { + ctx.font = '10px ' + SANS; + ctx.textBaseline = 'middle'; + for (var i = first(b, v[0]); i < b.n && b.s[i] < v[1]; i++) { + var a = ox(b.s[i]), z = ox(b.e[i]), nm = b.o.name[i]; + if (nm && z - a > measure('10px ' + SANS, nm) + 6) { + ctx.fillStyle = ['gpos75', 'gpos100'].indexOf(b.o.stain[i]) >= 0 ? INK : INK2; + ctx.fillText(nm, (Math.max(a, g) + Math.min(z, g + W)) / 2, L.bands + L.bh / 2); + } + hits.push({ kind: 'band', x0: a, x1: z, y0: L.bands, y1: L.bands + L.bh, name: nm, s: b.s[i], e: b.e[i] }); + } + } + (ch.masked || []).forEach(function (m) { + hits.push({ kind: 'masked', x0: ox(m[0]), x1: ox(m[1]), y0: L.bands, y1: L.bands + L.bh, s: m[0], e: m[1] }); + }); + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = INK3; + ctx.font = '11px ' + SANS; + ctx.fillText('Findings', g - 12, L.fy + 16); + ctx.fillText(phone ? 'Runs' : 'Homozygous runs', g - 12, L.ry + L.rh / 2); + ctx.textAlign = 'left'; + ctx.save(); + ctx.beginPath(); ctx.rect(g - 6, 0, W + 12, CH); ctx.clip(); + openFindings(L, v, ch); + openRoh(L, v, ch); + // The reading line: a dashed brass line through every lane at the centre, its place in a pill. + var cx = g + W / 2; + ctx.setLineDash([3, 3]); + ctx.strokeStyle = BRASS; + ctx.globalAlpha = k * 0.8; + ctx.beginPath(); ctx.moveTo(cx, L.ruler + 22); ctx.lineTo(cx, L.ry + L.rh + 6); ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + var pill = mb(cam.c, 1) + ' Mb', pw = measure('600 10.5px ' + SANS, pill) + 12; + ctx.fillStyle = BRASS; + rounded(ctx, cx - pw / 2, L.ruler - 20, pw, 15, 7.5); + ctx.beginPath(); rounded(ctx, cx - pw / 2, L.ruler - 20, pw, 15, 7.5); ctx.fill(); + ctx.fillStyle = PAGE; + ctx.font = '600 10.5px ' + SANS; + ctx.textAlign = 'center'; + ctx.fillText(pill, cx, L.ruler - 12.5); + ctx.restore(); + } + function laneWords(c, text, x, y) { + c.fillStyle = INK3; + c.font = 'italic 11.5px ' + SANS; + c.textAlign = 'left'; + c.textBaseline = 'middle'; + c.fillText(text, x, y); + } + function openFindings(L, v, ch) { + var lay = M.layers.findings || {}, g = gut(); + if (!landed(lay)) { laneWords(ctx, layerWords(lay), g + 4, L.fy + 16); return; } + var fs = drawn().filter(function (r) { return r.chrom === ch.n; }); + var inView = fs.filter(function (r) { return r.e > v[0] && r.s < v[1]; }); + if (!inView.length) { + laneWords(ctx, fs.length ? 'No finding in this stretch.' : noFindingHere(), g + 4, L.fy + 16); + return; + } + var used = []; + inView.sort(function (a, b) { return PRI[a.level] - PRI[b.level] || a.s - b.s; }).forEach(function (r) { + var x = ox((r.s + r.e) / 2), y = L.fy + 14; + if (r.e - r.s > v[2] / 2000) { + ctx.fillStyle = LV[r.level] || LV.info; + ctx.fillRect(ox(r.s), y + 9, Math.max(2, ox(r.e) - ox(r.s)), 2); + } + ctx.strokeStyle = LV[r.level] || LV.info; + ctx.globalAlpha = 0.5; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(x, y + 8); ctx.lineTo(x, L.bands + L.bh); ctx.stroke(); + ctx.globalAlpha = cam.k; + glyph(ctx, r.level, x, y, 6); + hits.push({ kind: 'finding', r: r, x: x, y: y, rad: 9 }); + var name = r.gene || r.title, words = U.LEVEL_WORDS[r.level] || r.level; + var w = Math.max(measure('500 12px ' + SANS, name), measure('10.5px ' + SANS, words)); + var lx = x + 11; + if (lx + w > CW - 4 || used.some(function (u) { return !(lx + w < u[0] || lx > u[1]); })) { return; } + used.push([lx - 4, lx + w + 6]); + ctx.textBaseline = 'middle'; + label(ctx, name, lx, y - 1, '500 12px ' + SANS, INK); + label(ctx, words, lx, y + 13, '10.5px ' + SANS, INK3); + }); + } + function openRoh(L, v, ch) { + var lay = M.layers.roh || {}, g = gut(), y = L.ry + L.rh / 2; + if (!landed(lay)) { laneWords(ctx, layerWords(lay), g + 4, y); return; } + var r = M.roh[ch.n]; + if (!r || !r.n) { laneWords(ctx, 'No run of homozygosity on this chromosome.', g + 4, y); return; } + for (var i = first(r, v[0]); i < r.n && r.s[i] < v[1]; i++) { + var a = ox(r.s[i]), z = ox(r.e[i]), art = M.art[r.o.art[i]] || ''; + if (r.o.long[i]) { + ctx.fillStyle = INK2; + ctx.fillRect(a, y - 3, Math.max(1, z - a), 6); + var t = mb(r.e[i] - r.s[i]) + ' Mb run of homozygosity', font = '11px ' + SANS; + if (measure(font, t) + 8 > z - a) { t = mb(r.e[i] - r.s[i]) + ' Mb'; } + if (measure(font, t) + 8 < z - a) { ctx.textBaseline = 'middle'; label(ctx, t, a + 4, y - 11, font, INK2); } + } else if (art) { + ctx.fillStyle = hatch; + ctx.fillRect(a, y - 3, Math.max(1, z - a), 6); + } else { + ctx.fillStyle = 'rgba(195,194,183,.35)'; + ctx.fillRect(a, y - 0.5, Math.max(1, z - a), 1); + } + hits.push({ kind: 'roh', x0: a, x1: Math.max(a + 3, z), y0: y - 6, y1: y + 6, s: r.s[i], e: r.e[i], art: art, long: r.o.long[i] }); + } + } + function noFindingHere() { + return closedAny() ? 'No open finding with a place on this chromosome.' : 'No finding with a place on this chromosome.'; + } + function closedAny() { return M.veilIds.some(function (id) { return stateOf(id) === 'closed'; }); } + function layerWords(lay) { + if (lay.state === 'missing') { return 'Not on the map: ' + (lay.note || ('step ' + lay.step + ' wrote no ' + (lay.source || 'file') + '.')); } + if (lay.state === 'unreadable') { return 'Not on the map: ' + (lay.source || '') + ' could not be read' + (lay.note ? ' (' + lay.note + ')' : '') + '.'; } + if (lay.state === 'not_run') { return 'Not on the map: step ' + lay.step + ' was not part of this run.'; } + if (lay.state === 'not_in_this_run') { return 'Not on the map: not part of this run.'; } + if (lay.state === 'running' || lay.state === 'waiting') { return 'Not on the map yet: step ' + lay.step + ' has not ended.'; } + if (lay.state === 'failed') { return 'Not on the map: step ' + lay.step + ' failed.'; } + if (lay.state === 'skipped') { return 'Not on the map: step ' + lay.step + ' was skipped.'; } + return lay.note || ''; + } + + // --- the camera --- + function moving() { + return Math.abs(cam.k - tgt.k) > 0.002 || Math.abs(cam.ls - tgt.ls) > 0.002 || Math.abs(cam.c - tgt.c) > Math.exp(cam.ls) * 0.0005; + } + var last = 0; + function step() { + var now = performance.now(), dt = last ? Math.min(64, now - last) : 16; + last = now; + if (reduced || cam.ci !== tgt.ci && cam.k < 0.01) { cam.ci = tgt.ci; } + if (reduced) { cam.c = tgt.c; cam.ls = tgt.ls; cam.k = tgt.k; return; } + if (cam.ci !== tgt.ci) { + // Another chromosome: close the open one first, then open the new one. + cam.k += (0 - cam.k) * (1 - Math.exp(-dt / 60)); + if (cam.k < 0.01) { cam.k = 0; cam.ci = tgt.ci; cam.c = tgt.c; cam.ls = tgt.ls; } + return; + } + cam.c += (tgt.c - cam.c) * (1 - Math.exp(-dt / 75)); + cam.ls += (tgt.ls - cam.ls) * (1 - Math.exp(-dt / 75)); + cam.k += (tgt.k - cam.k) * (1 - Math.exp(-dt / 95)); + if (!moving()) { cam.c = tgt.c; cam.ls = tgt.ls; cam.k = tgt.k; } + } + function schedule() { if (!raf) { raf = window.requestAnimationFrame(function () { last = last || performance.now(); draw(); }); } } + function clampTgt() { + var ch = M.chroms[tgt.ci], span = clamp(Math.exp(tgt.ls), minSpan(ch.len), ch.len); + tgt.ls = Math.log(span); + tgt.c = clamp(tgt.c, span / 2, ch.len - span / 2); + } + function openChrom(i, c, span, push) { + if (!M || i < 0 || i >= M.chroms.length) { return; } + var ch = M.chroms[i]; + tgt.ci = i; + tgt.k = 1; + tgt.c = c === undefined ? ch.len / 2 : c; + tgt.ls = Math.log(span === undefined ? ch.len : span); + clampTgt(); + if (cam.k < 0.01) { cam.ci = i; cam.c = tgt.c; cam.ls = tgt.ls; } + changed(push); + } + function closeChrom(push) { + tgt.k = 0; + changed(push); + } + function changed(push) { + touched = true; + last = 0; + schedule(); + writeHash(push); + } + + // --- the address: #genome, #chr7, #chr7:117.4-137.4M; never a closed result's id, gene or place --- + function locusWords() { + if (tgt.k < 0.5) { return 'genome'; } + var ch = M.chroms[tgt.ci], span = Math.exp(tgt.ls); + if (span >= ch.len * 0.999) { return ch.n; } + return ch.n + ':' + mb(tgt.c - span / 2) + '-' + mb(tgt.c + span / 2) + 'M'; + } + function writeHash(push) { + if (!M || !window.history || !window.history.replaceState) { return; } + window.clearTimeout(hashT); + var go = function () { + var h = '#' + locusWords(); + if (window.location.hash === h) { return; } + if (push) { window.history.pushState(null, '', h); } else { window.history.replaceState(null, '', h); } + }; + if (push) { go(); } else { hashT = window.setTimeout(go, 250); } + } + // A place in the address: 'genome', 'chr7', 'chr7:117.4-117.7M', 'chr7:120k-140k', 'chr7:117480026-117668665' + // (1-based, inclusive), or the aliases view=genome, view=chromosome&chr=7, chr=7. Returns null when the + // address is not a map place, false when it is one that cannot be read. + function parsePlace(h) { + var m, kv = {}; + if (/^genome$/i.test(h)) { return { k: 0 }; } + if (/^(view|chr)=/.test(h)) { + h.split('&').forEach(function (p) { var x = p.split('='); kv[x[0]] = decodeURIComponent(x[1] || ''); }); + if (kv.view === 'genome') { return { k: 0 }; } + if (!kv.chr) { return kv.view ? false : null; } + h = 'chr' + String(kv.chr).replace(/^chr/i, ''); + } + if (!/^chr/i.test(h)) { return null; } + var name = 'chr' + h.slice(3).split(':')[0].toUpperCase().replace(/^0+/, ''); + if (!(name in M.by)) { return false; } + var ch = M.chroms[M.by[name]], rest = h.indexOf(':') > 0 ? h.slice(h.indexOf(':') + 1) : ''; + if (!rest) { return { k: 1, ci: M.by[name] }; } + if ((m = /^([0-9.]+)-([0-9.]+)([MmKk])$/.exec(rest))) { + var u = /m/i.test(m[3]) ? 1e6 : 1e3, a = Number(m[1]) * u, z = Number(m[2]) * u; + if (!(z > a)) { return false; } + return { k: 1, ci: M.by[name], s: a, e: z }; + } + if ((m = /^([0-9,]+)-([0-9,]+)$/.exec(rest))) { + var s = Number(m[1].replace(/,/g, '')) - 1, e = Number(m[2].replace(/,/g, '')); + if (!(e > s)) { return false; } + return { k: 1, ci: M.by[name], s: s, e: e }; + } + void ch; + return false; + } + function readHash(scroll) { + if (!M) { return; } + var h; + try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return; } + if (!h) { return; } + var p = parsePlace(h); + if (p === null) { return; } + if (p === false) { say('Could not read the place “' + h + '”'); return; } + if (p.k === 0) { tgt.k = 0; } else { + var ch = M.chroms[p.ci]; + openChrom(p.ci, p.s === undefined ? ch.len / 2 : (p.s + p.e) / 2, p.s === undefined ? ch.len : p.e - p.s, false); + window.clearTimeout(hashT); + } + if (scroll) { + cam.ci = tgt.ci; cam.k = tgt.k; cam.c = tgt.c; cam.ls = tgt.ls; + fig.scrollIntoView({ block: 'start' }); + } + schedule(); + } + function say(words) { + toast.textContent = words; + toast.hidden = false; + window.clearTimeout(say.t); + say.t = window.setTimeout(function () { toast.hidden = true; }, 4000); + } + + // --- the margin: settled, not per frame --- + function settle() { + window.clearTimeout(settleT); + settleT = window.setTimeout(describe, 140); + } + function describe() { + if (!M) { return; } + var lv = level(), fs = drawn(), ch = chrom(); + fig.setAttribute('data-level', lv); + fig.setAttribute('data-chrom', lv === 'genome' ? '' : ch.n); + fig.setAttribute('data-view', locusWords()); + stopG.setAttribute('aria-pressed', String(lv === 'genome')); + stopC.setAttribute('aria-pressed', String(lv !== 'genome')); + crumb.textContent = ''; + crumb.hidden = lv === 'genome'; // the gauge already says Genome + var gl = el('button', { type: 'button', 'class': 'gm-crumb-g' }, 'Genome'); + gl.addEventListener('click', function () { closeChrom(true); }); + add(crumb, gl); + var nY = M.chroms.length; + if (lv === 'genome') { + title.textContent = M.label + '’s genome, all ' + nY + ' chromosomes'; + kicker.textContent = 'Figure 1 · Genome map · ' + M.label + '’s genome'; + mkick.textContent = 'The whole genome'; + mline.textContent = ''; + } else { + title.textContent = 'Chromosome ' + short(ch.n); + kicker.textContent = 'Figure 1 · Genome map · ' + M.label; + add(crumb, el('span', { 'class': 'sep', 'aria-hidden': 'true' }, ' › '), el('span', { 'aria-current': 'location' }, ch.n)); + mkick.textContent = 'Under the line'; + mline.textContent = ch.n + ' · ' + mb(cam.c, 2) + ' Mb' + bandAt(ch, cam.c); + } + canvas.setAttribute('aria-label', lv === 'genome' ? 'The genome map: ' + nY + ' chromosomes with ' + fs.length + ' findings drawn' + : 'Chromosome ' + short(ch.n) + ' open on the genome map'); + narr.textContent = ''; + var v = view(); + var shown = lv === 'genome' ? fs : fs.filter(function (r) { return r.chrom === ch.n && r.e > v[0] && r.s < v[1]; }); + var forms = (M.text.findings || {}); + if (lv === 'genome') { + var form = fs.length === 0 ? forms.none : fs.length === 1 ? forms.one : forms.many; + add(narr, runs((form || []).map(function (x) { return { t: x.t.replace('{n}', String(fs.length)), b: x.b }; }))); + } else if (M.text.chrom && M.text.chrom[ch.n]) { + add(narr, runs(M.text.chrom[ch.n])); + } + var lay = M.layers.findings || {}; + if (!landed(lay)) { + add(narr, el('p', {}, layerWords(lay))); + } else if (shown.length && !(phone && lv === 'genome')) { + // On a phone the genome level leaves the list to the sections under it: the page is long already. + var list = shown.slice().sort(function (a, b) { return PRI[a.level] - PRI[b.level] || M.by[a.chrom] - M.by[b.chrom] || a.s - b.s; }); + var p = el('p', { 'class': 'gm-phrases' }); + var cap = phone ? 3 : 6; + list.slice(0, cap).forEach(function (r, i) { if (i) { add(p, ' '); } add(p, r.phrase); }); + if (list.length > cap) { add(p, ' And ' + (list.length - cap) + ' more on the map.'); } + add(narr, p); + } else if (lv !== 'genome') { + add(narr, el('p', {}, fs.some(function (r) { return r.chrom === ch.n; }) ? 'No finding in this stretch.' : noFindingHere())); + } + if (lv === 'genome') { + add(narr, runs(M.text.genome)); + if (!phone) { add(narr, el('p', { 'class': 'dim' }, 'Click a chromosome to open it.')); } + } else { + var r = M.roh[ch.n], n = 0, longest = 0; + if (r) { for (var i = first(r, v[0]); i < r.n && r.s[i] < v[1]; i++) { n++; longest = Math.max(longest, r.e[i] - r.s[i]); } } + if (n && landed(M.layers.roh || {})) { + add(narr, el('p', {}, 'Runs of homozygosity in view: ' + n + ', the longest ' + mb(longest) + ' Mb.')); + } + if (!phone) { add(narr, el('p', { 'class': 'dim' }, 'Drag to move along it, scroll to zoom, Esc for the whole genome.')); } + } + drawLayers(); + drawMarks(); + } + function bandAt(ch, bp) { + var b = M.bands[ch.n]; + if (!b) { return ''; } + for (var i = first(b, bp); i < b.n && b.s[i] <= bp; i++) { + if (b.e[i] > bp) { return ' · band ' + short(ch.n) + b.o.name[i]; } + } + return ''; + } + function drawLayers() { + layersBox.textContent = ''; + [['findings', 'Findings'], ['roh', 'Runs of homozygosity']].forEach(function (x) { + var lay = M.layers[x[0]]; + var row = el('div', { 'class': 'gm-layer', 'data-layer': x[0], 'data-state': lay ? lay.state : 'missing' }); + var dd = el('dd', {}); + if (!lay) { add(dd, 'Not on the map: this server’s map has no ' + x[1].toLowerCase() + ' layer.'); } + else if (!landed(lay)) { add(dd, layerWords(lay)); } + else { + var n = x[0] === 'findings' ? drawn().length : lay.count; + add(dd, n === 0 && lay.empty ? lay.empty : n + ' on the map. '); + if (lay.note && n !== 0) { add(dd, lay.note); } + if (lay.caveat) { + var d = el('details', { 'class': 'gm-caveat' }); + add(d, el('summary', {}, 'Caveat'), el('p', {}, lay.caveat)); + add(dd, d); + } + } + add(row, el('dt', {}, x[1] + (lay && lay.step ? ' · step ' + lay.step : '')), dd); + add(layersBox, row); + }); + } + function drawMarks() { + marks.textContent = ''; + var lv = level(), ch = chrom(), v = view(); + drawn().filter(function (r) { return lv === 'genome' || (r.chrom === ch.n && r.e > v[0] && r.s < v[1]); }) + .sort(function (a, b) { return M.by[a.chrom] - M.by[b.chrom] || a.s - b.s; }).forEach(function (r) { + var a = el('a', { href: r.href, 'data-id': r.id, 'data-level': r.level, 'data-chrom': r.chrom, + 'data-s': String(r.s), 'data-e': String(r.e) }, (U.LEVEL_WORDS[r.level] || r.level) + ': ' + r.title + ', ' + r.chrom); + add(marks, add(el('li', {}), a)); + }); + } + function drawKey() { + keyBox.textContent = ''; + add(keyBox, el('div', { 'class': 'fig-label' }, 'Key')); + ['act', 'medicine', 'review', 'carrier', 'info'].forEach(function (lv) { add(keyBox, U.level(lv)); }); + add(keyBox, add(el('span', { 'class': 'gm-k-roh' }), el('span', { 'class': 'sw', 'aria-hidden': 'true' }), 'Run of homozygosity, 5 Mb or more')); + add(keyBox, add(el('span', { 'class': 'gm-k-mask' }), el('span', { 'class': 'sw', 'aria-hidden': 'true' }), 'Hatched: centromere or heterochromatin, few reads map there')); + } + + // --- the closed results: one dashed chip per kind, and the opened ones --- + function drawChips() { + chips.textContent = ''; + if (!M) { return; } + var acts = [], gates = [], opened = []; + M.veilIds.forEach(function (id) { + var s = stateOf(id); + if (s === 'closed') { (M.gated[id] ? gates : acts).push(id); } else if (s === 'opened') { opened.push(id); } + }); + if (acts.length) { + var c = el('div', { 'class': 'gm-chip closed', 'data-closed': 'act' }); + add(c, el('span', {}, (acts.length === 1 ? '1 result closed' : acts.length + ' results closed') + ' · Not drawn until opened on purpose')); + add(c, confirming === 'act' ? confirmBox(acts[0], false) : button('Open', function () { confirming = 'act'; drawChips(); })); + add(chips, c); + } + gates.forEach(function (id) { + var g = M.gated[id], name = g.gene || 'This result'; + var c = el('div', { 'class': 'gm-chip closed', 'data-closed': 'gate' }); + add(c, el('span', {}, name + ' closed · Not drawn until opened on purpose')); + add(c, confirming === id ? confirmBox(id, true) : button('Open ' + name, function () { confirming = id; drawChips(); })); + add(chips, c); + }); + opened.forEach(function (id) { + var r = M.findings.filter(function (x) { return x.id === id; })[0]; + var c = el('div', { 'class': 'gm-chip opened', 'data-opened': '1' }); + add(c, el('span', {}, 'Opened: ' + (r ? r.title : 'a result to act on'))); + add(c, button('Close it again', function () { confirming = null; opts.close(id); })); + add(chips, c); + }); + } + function confirmBox(id, gated) { + var box = el('span', { 'class': 'gm-confirm' }); + add(box, el('span', {}, gated ? 'It is kept behind its warning on every page. Open it here and in its section?' + : 'A result to act on in adult life is usually not looked at before the child can choose. Open it here and in its section?')); + add(box, button('Show the result', function () { confirming = null; opts.open(id); }, 'gm-yes')); + add(box, button('Keep it closed', function () { confirming = null; drawChips(); }, 'gm-no')); + return box; + } + function button(text, fn, cls) { + var b = el('button', { type: 'button', 'class': cls || '' }, text); + b.addEventListener('click', fn); + return b; + } + + // --- input --- + function at(e) { var r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; } + function hitAt(x, y) { + var best = null; + for (var i = hits.length - 1; i >= 0; i--) { + var h = hits[i]; + if (h.kind === 'finding') { if (Math.abs(h.x - x) <= h.rad && Math.abs(h.y - y) <= h.rad) { return h; } } + else if (x >= h.x0 && x <= h.x1 && y >= h.y0 && y <= h.y1) { + if (!best || (h.kind === 'masked' && best.kind === 'band') || (h.kind === 'roh')) { best = h; } + } + } + return best; + } + function tipFor(h) { + var ch = M.chroms[cam.ci]; + if (h.kind === 'finding') { return [h.r.phrase, 'Click for the finding’s page']; } + if (h.kind === 'band') { return ['Band ' + short(ch.n) + (h.name || ''), ch.n + ':' + comma(h.s + 1) + '-' + comma(h.e)]; } + if (h.kind === 'masked') { + return ['Not readable here · ' + ch.n + ':' + comma(h.s + 1) + '-' + comma(h.e), + 'Centromere, heterochromatin or acrocentric arm: few reads map, so layers are cut here. Not missing data.']; + } + if (h.kind === 'roh') { + return [mb(h.e - h.s) + ' Mb run of homozygosity' + (h.art ? ' (' + h.art + ': not a real run)' : ''), ch.n + ':' + comma(h.s + 1) + '-' + comma(h.e)]; + } + if (h.kind === 'row') { return null; } + return null; + } + // A hidden tooltip keeps no words: it may have named a result that is closed now. + function hideTip() { tip.hidden = true; tip.textContent = ''; } + function showTip(h, x, y) { + var t = h && tipFor(h); + if (!t) { hideTip(); return; } + tip.textContent = ''; + add(tip, el('b', {}, t[0])); + if (t[1]) { add(tip, el('span', {}, t[1])); } + tip.hidden = false; + tip.style.setProperty('left', Math.min(x + 12, Math.max(0, CW - 260)) + 'px'); + tip.style.setProperty('top', (y + 14) + 'px'); + } + var drag = null; + canvas.addEventListener('pointermove', function (e) { + if (!M) { return; } + var p = at(e); + if (drag && level() !== 'genome') { + var v = view(); + tgt.c = drag.c - (p[0] - drag.x) / plotW() * v[2]; + clampTgt(); + cam.c = tgt.c; + drag.moved = drag.moved || Math.abs(p[0] - drag.x) > 3; + changed(false); + return; + } + var h = hitAt(p[0], p[1]); + var row = level() === 'genome' && h ? (h.kind === 'row' ? h.i : h.kind === 'finding' ? M.by[h.r.chrom] : -1) : -1; + if (row !== hover.row) { hover.row = row; schedule(); } + hover.hit = h; + canvas.style.setProperty('cursor', h && (h.kind === 'finding' || h.kind === 'row') ? 'pointer' : level() === 'genome' ? 'default' : 'grab'); + showTip(h, p[0], p[1]); + }); + canvas.addEventListener('pointerleave', function () { hover.row = -1; hideTip(); schedule(); }); + canvas.addEventListener('pointerdown', function (e) { + if (!M || level() === 'genome' || e.button !== 0) { return; } + drag = { x: at(e)[0], c: tgt.c, moved: false }; + if (canvas.setPointerCapture) { canvas.setPointerCapture(e.pointerId); } + }); + canvas.addEventListener('pointerup', function (e) { + var was = drag; + drag = null; + if (!M || (was && was.moved)) { if (was && was.moved) { writeHash(false); } return; } + var p = at(e), h = hitAt(p[0], p[1]); + if (h && h.kind === 'finding') { window.location.href = h.r.href; return; } + if (level() === 'genome' && h && h.kind === 'row') { openChrom(h.i, undefined, undefined, true); } + }); + canvas.addEventListener('wheel', function (e) { + if (!M || level() === 'genome') { return; } + e.preventDefault(); + var p = at(e), v = view(), ch = chrom(); + var bp = v[0] + (p[0] - gut()) / plotW() * v[2]; + var f = Math.exp(clamp(e.deltaY, -200, 200) * 0.0025); + if (f > 1 && Math.exp(tgt.ls) >= ch.len * 0.999) { closeChrom(true); return; } + var span = clamp(Math.exp(tgt.ls) * f, minSpan(ch.len), ch.len); + tgt.c = bp - (bp - tgt.c) * span / Math.exp(tgt.ls); + tgt.ls = Math.log(span); + clampTgt(); + changed(false); + }, { passive: false }); + canvas.addEventListener('keydown', function (e) { + if (!M || e.altKey || e.ctrlKey || e.metaKey) { return; } + var open = level() !== 'genome', span = Math.exp(tgt.ls); + var done = true; + if (e.key === 'Escape' || e.key === '0') { if (open) { closeChrom(true); } else { done = false; } } + else if (e.key === '[' || e.key === ']') { + var i = (open ? tgt.ci : (e.key === ']' ? -1 : M.chroms.length)) + (e.key === ']' ? 1 : -1); + openChrom((i + M.chroms.length) % M.chroms.length, undefined, undefined, true); + } else if (open && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '−')) { + tgt.ls = Math.log(span * (e.key === '-' || e.key === '−' ? 1.6 : 1 / 1.6)); + clampTgt(); + changed(false); + } else if (open && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + tgt.c += (e.key === 'ArrowLeft' ? -0.2 : 0.2) * span; + clampTgt(); + changed(false); + } else if (!open && e.key === 'Enter') { openChrom(tgt.ci, undefined, undefined, true); } + else { done = false; } + if (done) { e.preventDefault(); } + }); + stopG.addEventListener('click', function () { closeChrom(true); }); + stopC.addEventListener('click', function () { + if (level() !== 'genome') { openChrom(tgt.ci, undefined, undefined, true); return; } + // From the genome: the chromosome of the first finding by level, else chromosome 1. + var f = drawn().slice().sort(function (a, b) { return PRI[a.level] - PRI[b.level] || M.by[a.chrom] - M.by[b.chrom]; })[0]; + openChrom(f ? M.by[f.chrom] : 0, undefined, undefined, true); + }); + window.addEventListener('hashchange', function () { readHash(false); }); + window.addEventListener('popstate', function () { readHash(false); }); + if (window.ResizeObserver) { new window.ResizeObserver(function () { if (M) { size(); clampTgt(); schedule(); } }).observe(stage); } + + // --- loading --- + function fail(why, words) { + failure = why; + fig.setAttribute('data-pgp-map', why); + stage.hidden = true; + marks.hidden = true; + fallback.hidden = false; + fallback.textContent = ''; + add(fallback, el('p', { 'class': 'gm-fail' }, words)); + var rows = opts.fallback ? opts.fallback() : []; + if (rows.length) { + add(fallback, U.table({ columns: ['Level', 'Finding', 'Where'], rows: rows }, 12)); + } else { + add(fallback, el('p', { 'class': 'dim' }, 'No finding with a place on the map is open on this page; every finding is listed in its section.')); + } + narr.textContent = ''; + layersBox.textContent = ''; + } + function load(k) { + key = k; + if (typeof window.DecompressionStream === 'undefined' || !window.Response || !window.ReadableStream) { + fail('nodecompress', 'This browser cannot open the map file. The findings with a place are listed here instead.'); + return; + } + fig.setAttribute('data-pgp-map', 'loading'); + fetch(opts.url, { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { + if (r.status === 401) { window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); return null; } + if (!r.ok) { + return r.text().then(function (t) { throw new Error((t || '').trim() || ('the server answered ' + r.status)); }, function () { throw new Error('the server answered ' + r.status); }); + } + var t1 = performance.now(); + return new Response(r.body.pipeThrough(new window.DecompressionStream('gzip'))).text().then(function (txt) { + var p0 = performance.now(); + var d = JSON.parse(txt); + var m = prepare(d); + var p1 = performance.now(); + return { m: m, parse: p1 - p0, arrived: t1 }; + }); + }).then(function (x) { + if (!x || k !== key) { return; } + var fresh = !M; + M = x.m; + failure = null; + stage.hidden = false; + marks.hidden = false; + fallback.hidden = true; + hatch = hatch || pattern(0.16); + fig.setAttribute('data-pgp-map-parse-ms', String(Math.round(x.parse))); + fig.removeAttribute('data-pgp-map-paint-ms'); + t0 = x.arrived; + size(); + if (fresh) { + tgt.ci = cam.ci = 0; + tgt.c = cam.c = M.chroms[0].len / 2; + tgt.ls = cam.ls = Math.log(M.chroms[0].len); + } + cacheKey = ''; + drawKey(); + drawChips(); + draw(); + describe(); + fig.setAttribute('data-pgp-map', 'drawn'); + if (fresh) { readHash(true); } + }).catch(function (err) { + if (k !== key) { return; } + fail('failed', 'The map could not be loaded: ' + String((err && err.message) || err).split('\n')[0] + + ' The findings with a place are listed here instead.'); + }); + } + + return { + node: fig, + // The page drew again: maybe new results (a new key fetches the document again), maybe a result + // opened or closed (the veil is asked again). + refresh: function (k) { + if (k !== key) { load(k); } + if (failure) { + fail(failure, fallback.firstChild ? fallback.firstChild.textContent : ''); + return; + } + if (!M) { return; } + cacheKey = ''; + hideTip(); // a tooltip of a result closed since leaves the page + drawChips(); + schedule(); + describe(); + } + }; + } + + window.PGPMap = { create: create, prepare: prepare }; +}()); diff --git a/assets/live/person.js b/assets/live/person.js index 6dcd7490f..83ad5a151 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -1,7 +1,7 @@ // 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 +// paragraph built from the findings, the counts by level, the genome map of the findings (map.js), 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 @@ -21,6 +21,7 @@ // '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 genomeMap = null, resultsKey = null; // Figure 1 (map.js), and the results it was built for var panel = document.getElementById('results'); var rail = document.getElementById('rail'); var NOT_ALL_CLEAR = 'No finding in what these steps assessed.'; @@ -428,8 +429,10 @@ add(text, lineList(lines)); if (c.hidden) { // APOE and the like: behind their warning, opened on purpose. - var d = el('details', { 'class': 'behind' }); + // Its fold is the map's APOE switch too (map.js): opening it here opens APOE on the map, and back. + var d = el('details', { 'class': 'behind', 'data-gate': 'apoe' }); add(d, el('summary', {}, c.hidden.summary), el('p', {}, c.hidden.text)); + d.addEventListener('toggle', function () { if (d.open !== !!shown.apoe) { setShown('apoe', d.open); } }); add(text, d); } if (c.explore && c.explore.length) { @@ -527,6 +530,37 @@ return !!(t && tagClosed(t)); } function openFinding(f) { shown[f.id] = true; var t = findingTag(f); if (t) { shown[t] = true; } } + // The genome map's veil (map.js): 'closed' while this page keeps the result closed, 'opened' once it was + // opened on purpose, 'open' when this page never closes it. A gated result (APOE, behind its warning) is + // closed on every page, an adult's too, until opened; a tag naming no finding of these results stays closed. + function mapVeil(id, gated) { + if (gated) { return shown[id] ? 'opened' : 'closed'; } + if (!closedPage()) { return 'open'; } + var f = findingById(id); + if (!f || veiled(f)) { return 'closed'; } + return f.level === 'act' || findingTag(f) ? 'opened' : 'open'; + } + // One switch for a result, from the map's chip or the card's "Show the result" (openFinding): opening it in + // one place opens it in both. Closing it closes the findings that were opened with it. + function setShown(id, on) { + var f = findingById(id); + if (on) { if (f) { openFinding(f); } else { shown[id] = true; } } else { + delete shown[id]; + ((results && results.findings) || []).forEach(function (x) { if (findingTag(x) === id) { delete shown[x.id]; } }); + } + redraw(true); + } + // Whether the page shows a finding of the map at this moment: its card's steps are done (a replay draws + // the run's end results from its first frame). A row with no finding of these results (the map's own APOE + // row) follows its card's fold instead. + function mapNow(id) { var f = findingById(id); var c = f && cardById(f.card); return !f || !!(c && gate(c) === 'show'); } + // Without the map (a browser with no DecompressionStream, or a map the server could not build): the findings + // with a place, as a table, each through this page's veil first. + function mapFallback() { + return findingsNow().filter(function (f) { return f.viewer && !veiled(f); }).map(function (f) { + return [U.LEVEL_WORDS[f.level] || f.level, f.title, f.viewer]; + }); + } 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 }); @@ -843,22 +877,16 @@ 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); + // Figure 1: the genome map (map.js), one figure kept across redraws so its camera stays where the + // reader left it. It asks this page's veil for each closed row, and opens or closes a result through it. + if (window.PGPMap) { + if (!genomeMap) { + genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, open: function (id) { setShown(id, true); }, + close: function (id) { setShown(id, false); }, fallback: mapFallback }); + } + add(slot, genomeMap.node); + genomeMap.refresh(resultsKey); + } } // --- the table of contents, the phone tab bar, the cover ------------------------------------------ @@ -1106,6 +1134,7 @@ if (key !== fetchedFor) { return; } if (!(r && (r.chapters || r.note))) { fetchedFor = null; return; } results = r; + resultsKey = key; redraw(true); route(); }).catch(function () { if (key === fetchedFor) { fetchedFor = null; } }); @@ -1145,6 +1174,8 @@ drawLead(); drawSections(); restoreFolds(); + // A gated result's fold follows its switch, whatever fold state the reader left (setShown). + Array.prototype.forEach.call(panel.querySelectorAll('details[data-gate]'), function (d) { d.open = !!shown[d.getAttribute('data-gate')]; }); 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.'; From 39749a0b3072354407db70bb498836911785f970 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:28:26 +0100 Subject: [PATCH 10/27] ci(docs): build og so the demo site carries the map documents The demo build needs og to write map.json for each run. Docs now builds og, checks that each demo run has its map, and uploads screenshots of the map as the map-screens artifact. Bead: pgp-iqon.8 --- .github/workflows/docs.yml | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fe99ccddf..87f0a1229 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -31,6 +31,9 @@ on: - 'bin/**' - 'tests/demo/**' - 'tests/fixtures/live/**' + # The demo's Figure 1, the genome map, is built by og (og map build) from these. + - 'rust/**' + - 'tests/fixtures/map/**' # No paths filter on pull requests, on purpose: a check skipped by a path # filter stays Pending and blocks the merge once it is a required check # (repos.md step 5 makes green checks required). The build takes seconds. @@ -92,10 +95,41 @@ jobs: # (tests/demo/build_live_demo.py; standard library only). It is a static # folder beside the pages, not a page of the nav, so it goes in after the # site check. + # The demo's Figure 1 is the genome map, whose documents og writes (og map build). The map reads no + # BAM, so a native build without the bam feature is all it needs: no htslib, no container, and the + # cache keeps it to seconds. + - name: Install the toolchain rust/rust-toolchain.toml pins + run: | + channel=$(sed -n 's/^channel = "\(.*\)"$/\1/p' rust/rust-toolchain.toml) + rustup toolchain install "$channel" --profile minimal + rustc --version + + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + workspaces: rust + + - name: Build og for the demo's map + working-directory: rust + run: cargo build --release --locked -p og --no-default-features + - name: Add the live-site demo run: | - python3 tests/demo/build_live_demo.py --out site/demo + python3 tests/demo/build_live_demo.py --out site/demo --og rust/target/release/og test -s site/demo/runs/DEMO-001/frames.json + for r in DEMO-001 DEMO-002 DEMO-003; do test -s "site/demo/runs/${r}/map.json"; done + + # The map at 1440 x 900 and 390 x 844, dark: genome, chromosome and a closed page, kept as the + # artifact map-screens (tests/test_live_demo.py --map-screens; Chrome is on the runner). + - name: Screenshots of the genome map + run: python3 tests/test_live_demo.py --map-screens "$RUNNER_TEMP/map-screens" site/demo + + - name: Upload the map screenshots + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: map-screens + path: ${{ runner.temp }}/map-screens/*.png + if-no-files-found: error + retention-days: 14 # Push to main only: workflow_dispatch can be run on any branch, and the # push filter above does not apply to it. Same line as docs-pages-helm.yml. From 77c4d0c0a11eafb43133e1ddddc0155314102e22 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:28:26 +0100 Subject: [PATCH 11/27] docs(live): describe Figure 1, the genome map Bead: pgp-iqon.8 --- docs/live-site.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/live-site.md b/docs/live-site.md index 11b784bc2..025b3f34c 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -10,7 +10,7 @@ https://geiserx.github.io/OwnGenome/demo/ is the live site on DEMO-001, the inve ![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. +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 map of each person page is the document `og map build` writes for that run, built once when the demo is. The genome viewer does not work: it opens your own reads on your own computer, so its links lead to a page that says it is not in the demo. What differs from your own site: @@ -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. A run with no end that has written nothing for a long time reads "Stopped" instead, with the day it was last heard from, and comes after the runs still going: twice the longest step's usual hours (27 h, as DeepVariant's 13.5 h can pass with no message) and never under 6 h. It reads "Being written" again as soon as something new arrives (a message, a trace row, a status row). 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; 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: +**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 genome map. 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 its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. +A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. A stopped run's page says so under the name and in the summary line, with the day it was last heard from, and warns that the report may be incomplete; a section still waiting for its step says the run stopped before that step ended. @@ -70,6 +70,8 @@ While the run goes, the page still opens on the report being written. A short ba The header search finds a gene or rsID across the results and opens the card that names it; on a page that keeps results closed it leaves out every hit that names one (above). One row of the results that the page cannot read (a field of the wrong type) costs that row only: it shows as a finding on its card, "A row of this section could not be read", at the highest level its rule can give (to act on for a rule that can give one, so a page that keeps results closed keeps that card closed too), the card whose table cannot be drawn says so, and every other card and finding is on the page. `#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. +**Figure 1, the genome map.** The 24 chromosomes as rows, to scale (23 for a person with no Y), each with its bands, the centromere as a waist and the stretches few reads can map hatched (centromeres, heterochromatin, the acrocentric arms). Each finding with a place sits on its chromosome as its level's shape, with its gene beside it when the name fits; runs of homozygosity of 5 Mb or more are a line under the row, shorter ones a faint band. A finding that names a gene but no position (a medicine gene, Lp(a)) sits on that gene's span; a finding with neither stays in its section only. Click a chromosome (or, with the map focused, press `[` or `]`) and it opens in place: an overview with the part in view, a ruler in Mb, the bands with their names, a findings lane with each gene and its level's words, and a lane for the runs of homozygosity, each run with its length. Drag to move along it, scroll to zoom in (as far as about 10 Mb across a wide screen; closer views come later), and press Esc (or click Genome) to go back. A dashed line marks the centre, and the margin says what is under it: the chromosome, the position and the band, the findings in view and the runs. Every sentence in the margin is one the map document carries; the page only fills in the counts. The address follows the map: `#genome`, `#chr7`, or `#chr7:117.4-137.4M`, and a link to one of them opens that view; a place the page cannot read shows a short note and leaves the map as it was. The map comes from `/runs//map.json`, which the map plugin (`bin/owngenome_live_map.py`) builds with `og map build` from the run folder and the results the page gets (`rust/crates/og-map/README.md` describes the document). It needs og on the host (`scripts/setup.sh --og`); without it, or in a browser that cannot unpack the file (no `DecompressionStream`), the figure says so and lists the findings with a place in a table instead. A layer whose file is missing or cannot be read says so in words, never as a blank lane. + **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//`). 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, or why the child was not assessed. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. On a phone the grid becomes a list of pairs. From f29223760904af9c9d691edd47edeadbafe5df52 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:25:11 +0100 Subject: [PATCH 12/27] fix(live): close every place of a child on the family page while any result of theirs is closed What the family page showed of a child depended on which result was closed and where it lay: a missing side-by-side row named the gene, the painting closed only when a grandparent stretch lay over the result, one 'Show the rows' opened every table, and the couple screen had no veil at all. Now, while a child has a result still closed (or the page cannot read the child's results), the painting's grandparents, the map image, every de novo and compound-het row, the child's medicines and HLA cells and the screen of a couple the child is part of close in the same way, one box each. Each click opens one result; under an unreadable state each box opens its own place. core.js veilId()/veilIds() lose their only caller and are removed. --- CHANGELOG.md | 2 +- assets/live/core.js | 49 +-- assets/live/family-page.js | 207 +++++---- bin/live_results.py | 7 +- docs/live-site.md | 4 +- tests/demo/make_demo_sample.py | 2 +- tests/fixtures/live/demo/README.md | 2 +- tests/test_live_demo.py | 636 +++++++++++++++++----------- tests/test_live_results.py | 64 +-- tests/test_owngenome_live_family.py | 15 + 10 files changed, 520 insertions(+), 468 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69de5401e..d309c0213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Each release's notes. From 1.0.0 on, a major or minor release has hand-written n ## Unreleased -- The family page now keeps a child's results to act on closed, as the child's own page does. A de novo candidate or a compound-het pair that names a closed result, and a side-by-side row whose value in the child's column names one (a CPIC gene such as RYR1), stay behind "Show the result" until that result is opened there; a reload closes them again. While a stretch painted from a grandparent lies over a closed result, the child's painting shows every stretch from its parent alone. While the page cannot read the child's results, every such row of that child stays closed (in the side by side, every medicines and HLA row). On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: the files behind the pages hold every row. +- The family page now keeps a child's results to act on closed, as the child's own page does, and what it shows of a child no longer depends on which result is closed or where it lies. While a child has a result still closed, or the page cannot read the child's results, these close for that child, one box each: the painting's grandparents (every stretch drawn from its parent alone), the chromosome map image, all de novo and compound-het rows, the child's medicines and HLA cells in the side by side, and the carrier screen of a couple the child is part of. Each "Show the result" opens one result; the places come back once the last one is opened, and a reload closes them again. While the results cannot be read, each box opens only its own place. On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too, on a phone in the card's line as well. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: `results.json` and `family.json` hold every row. - Step 49 (clonal haematopoiesis) now prepares the CHIP gene models and writes its two tables with the Rust `og` binary: `og chip prepare`, `og chip report` and `og chip check-table` replace the commands of `bin/chip_report.py`, which keeps only the detection limit and the column names the reports import. On Ensembl 116's full GFF3, prepare takes 1.7 s and 6 MB instead of 15 s and 20 MB. The two tables, `chip.gff3` and `chip.bed` are the same, byte for byte. What changes: a missing input file or a position that is not a whole number stops with an `ERROR:` line and exit 1, not a Python traceback, and each output is written whole or not at all. Step 49 now needs `og` (`scripts/setup.sh --og`). - VEP consequence ranking (steps 23 and 31) and the gnomAD constraint join now run in the Rust `og` binary, inside `BCFTOOLS_IMAGE`: `og vcf csq-rank` replaces `bin/csq_rank.awk` and `og table constraint-join` replaces `bin/constraint_join.awk`, which are gone. Neither awk program runs on the host anymore (step 23 still uses the host's `awk` for small table edits), and the ranker is no longer the slow stage of step 23's main pipe. Steps 23 and 31 now need `og`: `scripts/setup.sh --og` builds it. Their output is the same, byte for byte, except for these changes. diff --git a/assets/live/core.js b/assets/live/core.js index fff06e955..723309e3c 100644 --- a/assets/live/core.js +++ b/assets/live/core.js @@ -219,55 +219,8 @@ add(v, b); return v; } - // The id of the closed result a row belongs to, or null: bin/live_results.py veil_id() word for word, for the - // rows only the family page holds (a child's de novo and compound-het candidates). spans: results.json's - // veil.spans, [[id, gene, chrom, start, end]]. text: what the row shows (its gene names; its loci are read - // too); chrom, pos and end its own position. tests/test_live_results.py runs it under node against veil_id(). - var LOCUS_RE = /(?= s[3]; })) { return s[0]; } - } - return null; - } - // Every closed result a row belongs to, by the same rule taken one result at a time: a compound-het pair can - // name two, and a painted stretch can hold two, so the family page keeps such a row closed until each is opened. - function veilIds(spans, text, chrom, pos, end) { - return (spans || []).filter(function (s) { return veilId([s], text, chrom, pos, end); }).map(function (s) { return s[0]; }); - } - 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, 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, veil: veil, veilId: veilId, veilIds: veilIds }; + hm: hm, dm: dm, hours: hours, veil: veil }; }()); diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 174352935..584ed17d3 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -7,9 +7,8 @@ // 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. // A child's results to act on stay closed here as on the child's own page (person.js): each child's results.json -// gives the closed results (veil.spans), and a row of the child that names one (a de novo or compound-het -// candidate, a side-by-side row) stays closed until that result is opened on purpose here; while a stretch -// painted from a grandparent lies over one, the child's painting shows each stretch's parent only. +// gives the closed results (veil.spans), and while one is closed every place of this page that could carry one +// closes for that child, the same whichever result it is (the veil below). (function () { 'use strict'; var U = window.PGPUI; @@ -55,6 +54,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 (coupleShut(f, c).length) { out.push([c.a + ' and ' + c.b + '’s carrier screen stays closed in chapter III. ', false]); return; } 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]); } @@ -76,14 +76,21 @@ // --- the veil --------------------------------------------------------------------------------------- // What this page knows of each child's results: { status: 'pending', 'known' or 'failed', spans, acts }. A child - // with no run here, or whose results.json has not answered or could not be read, cannot say which rows name a - // result to act on, so every row of theirs that could stays closed (UNKNOWN) until opened on purpose. - // Opened results are kept for the life of this page, as on the child's page: a reload closes them again. They - // are kept per child and per run (two children can carry the same result, under the same id). + // with no run here, or whose results.json has not answered or could not be read, cannot say whether a result to + // act on is closed (UNKNOWN). + // What the page shows of a child never depends on which result is closed or where it lies: while a child has any + // result to act on still closed here, or while the page cannot tell, every place that could carry one closes for + // that child in the same way, with one box each and the same words whatever the result. The places: the + // painting's grandparents, the map as an image, the de novo and compound-het rows, the child's medicines and HLA + // cells of the side by side, and the carrier screen of a couple the child is part of. A known result opens with + // its own click, from any of those boxes; the places open once the last one is. While the page cannot tell, each + // box opens only its own place. Opened results are kept for the life of this page, as on the child's page: a + // reload closes them again. They are kept per child and per run (two children can carry the same result id). var UNKNOWN = '?'; var GENE_GROUPS = { Medicines: true, HLA: true }; // the side-by-side groups whose rows name a gene - var kids = {}, opened = {}, openedAll = {}, mapShown = {}, last = null; + var kids = {}, opened = {}, last = null; function openKey(child, id) { return (kids[child] && kids[child].url) + '|' + child + '|' + id; } + function isChild(f, s) { return (f.state.children || []).some(function (c) { return c.child === s; }); } function loadKids(f) { (f.state.children || []).forEach(function (c) { var url = memberUrl(f, c.child); @@ -100,7 +107,8 @@ // Results the server could not build come back without their findings: as unreadable as no answer. var usable = !!(r && Array.isArray(r.findings)); var acts = usable ? r.findings.filter(function (x) { return x.level === 'act'; }).map(function (x) { return x.id; }) : []; - var next = usable ? { status: 'known', url: url, spans: v.spans || null, acts: acts } : { status: 'failed', url: url }; + var next = usable ? { status: 'known', url: url, spans: Array.isArray(v.spans) ? v.spans : null, acts: acts } + : { status: 'failed', url: url }; // An answer that cannot be read keeps what was known: the page never opens on a failed request. if (!usable && cur.status === 'known') { return; } var key = JSON.stringify([next.status, next.spans, next.acts]); @@ -116,47 +124,38 @@ }); }); } - // The closed results a row of CHILD belongs to: their ids, [UNKNOWN] when this page cannot tell, or []. - // COORDS: the row has a position and no gene name (a de novo candidate, a stretch). A closed result known by its - // gene alone (two changes in one gene, with no position) could sit at that position, and only the gene would - // tell: such a row stays closed with every result of that kind too. - function tagOf(child, text, chrom, pos, end, coords) { + // The results of CHILD still closed, as the place BOX sees them: their ids, [UNKNOWN] while this page cannot read + // them (until BOX's own button opens BOX), or [] once every one is opened. Results with act findings but no + // veil.spans cannot say which they are: UNKNOWN too. + function shutOf(child, box) { var k = kids[child]; - if (!k || k.status !== 'known') { return [UNKNOWN]; } - if (!k.spans) { return k.acts.length ? [UNKNOWN] : []; } // results without the tags: cannot tell - var ids = U.veilIds(k.spans, text, chrom, pos, end); - if (coords) { k.spans.forEach(function (s) { if (!s[2] && ids.indexOf(s[0]) < 0) { ids.push(s[0]); } }); } - return ids; - } - // The results of TAGS still closed for CHILD: a row stays closed while one of them is. - function stillClosed(child, tags) { - return tags.filter(function (t) { return t === UNKNOWN ? !openedAll[child] : !opened[openKey(child, t)]; }); - } - // The same for a whole drawing of CHILD (its painting, its map as an image): while this page cannot read the - // child's results, only the drawing's own button (KEY) opens it, never "Show the rows" of a table. - function drawingClosed(child, tags, key) { - return tags.filter(function (t) { return t === UNKNOWN ? !mapShown[openKey(child, key)] : !opened[openKey(child, t)]; }); - } - // The rows of CHILD split by the veil: { open: rows, closed: [the still closed results of each closed row] }. - function veiled(child, rows, tagFn) { - var out = { open: [], closed: [] }; - rows.forEach(function (r) { - var t = stillClosed(child, tagFn(r)); - if (t.length) { out.closed.push(t); } else { out.open.push(r); } - }); + if (!k || k.status !== 'known' || (!k.spans && k.acts.length)) { + return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN]; + } + var out = []; + (k.spans || []).forEach(function (s) { if (!opened[openKey(child, s[0])] && out.indexOf(s[0]) < 0) { out.push(s[0]); } }); return out; } - // The closed box under a table of CHILD: how many rows stay closed, and one button that opens one result. - function veilBox(child, closed, what, whats) { - if (!closed.length) { return null; } - var n = closed.length === 1 ? 'One ' + what + ' stays' : closed.length + ' ' + whats + ' stay'; - var first = closed[0][0]; - if (first === UNKNOWN) { - return U.veil(n + ' closed: this page cannot read ' + child + '’s results yet, so it cannot tell which ' + whats + - ' name a result to act on in adult life.', 'Show the ' + whats, function () { openedAll[child] = true; redraw(); }); - } - return U.veil(n + ' closed with the result to act on in adult life ' + (closed.length === 1 ? 'it belongs' : 'they belong') + - ' to, until that result is opened.', null, function () { opened[openKey(child, first)] = true; redraw(); }); + // The closed box of the place BOX of CHILD: WHAT says what stays closed; one button opens one result (BUTTON + // while the page cannot read the child's results, and then it opens this place only). + function veilBox(child, shut, box, what, button) { + if (!shut.length) { return null; } + var v = shut[0] === UNKNOWN + ? U.veil(what + ': closed, as this page cannot read ' + child + '’s results yet, so it cannot tell whether a ' + + 'result to act on in adult life is among them.', button, function () { opened[openKey(child, 'box:' + box)] = true; redraw(); }) + : U.veil(what + ': closed while ' + child + ' has a result to act on in adult life that is closed here.', null, + function () { opened[openKey(child, shut[0])] = true; redraw(); }); + v.setAttribute('data-veil-child', child); + v.setAttribute('data-veil-box', box); + return v; + } + + // A couple's carrier screen names the partners' carrier genes and their positions, so it closes while a partner + // who is a child of this family has a result still closed: [[that partner, their closed results]]. + function coupleShut(f, c) { + return [c.a, c.b].filter(function (m) { return isChild(f, m); }) + .map(function (m) { return [m, shutOf(m, 'couple:' + c.a + ' ' + c.b)]; }) + .filter(function (x) { return x[1].length; }); } // The pedigree drawn from the layout the server gives: a sequenced person with a run here is a link. @@ -261,6 +260,16 @@ add(s, p); return; } + var cs = coupleShut(f, c); + if (cs.length) { + // The whole screen closes: its counts, its boxes, its table and its report. + p.textContent = c.a + ' and ' + c.b + ': the carrier screen stays closed here.'; + add(s, p); + cs.forEach(function (x) { + add(s, veilBox(x[0], x[1], 'couple:' + c.a + ' ' + c.b, 'The carrier screen of ' + c.a + ' and ' + c.b, 'Show the screen')); + }); + return; + } var rows = (f.couples || {})[c.a + ' ' + c.b] || []; var one = rows.filter(function (r) { return r.result === 'one_partner'; }).length; // A count of zero is not a clean result: the screen sees only what it lists as assessed. @@ -377,15 +386,12 @@ var SIDE = { paternal: 'father', maternal: 'mother' }; function painting(f, child, all) { // A stretch drawn from a grandparent says which grandparent's copy the child got there, so one over a - // closed result says whether the child got it. A stretch from a parent alone says nothing. Closing only that - // stretch would point at the closed result (a hatch, a gap in the table), so while one is closed every stretch - // of this child is drawn from its parent alone, and no grandparent, basis or closed mark is shown. - var shut = [], pk = openKey(child, 'paint'); - all.forEach(function (r) { - if (!r.ancestor || r.ancestor === '.') { return; } - drawingClosed(child, tagOf(child, r.chrom + ':' + r.start + '-' + r.end, r.chrom, r.start, r.end, true), 'paint') - .forEach(function (x) { if (shut.indexOf(x) < 0) { shut.push(x); } }); - }); + // closed result says whether the child got it. A stretch from a parent alone says nothing. Closing only the + // stretches over a result, or the painting only when one lies over it, would point at where the result lies, so + // while the child has any result still closed every stretch is drawn and listed from its parent alone, with no + // grandparent, basis or closed mark, behind one box. + var anc = all.some(function (r) { return r.ancestor && r.ancestor !== '.'; }); + var shut = anc ? shutOf(child, 'paint') : []; var rows = !shut.length ? all : all.map(function (r) { return { side: r.side, parent: r.parent, chrom: r.chrom, start: r.start, end: r.end, sites: r.sites, discordant: r.discordant, ancestor: '.', basis: 'parent' }; @@ -434,16 +440,8 @@ }); add(leg, el('span', { 'class': 'dim' }, ' Grey: no stretch called (no informative site, a gap of the reference).')); add(fig, leg); - if (shut.length) { - // One box for the whole painting: a count of closed stretches would say how many sit over the result. - add(fig, shut[0] === UNKNOWN - ? U.veil('The grandparents of ' + child + '’s stretches stay hidden: this page cannot read ' + child + '’s results yet, ' + - 'so it cannot tell which stretch lies over a result to act on in adult life. Each stretch shows its parent only.', - 'Show the grandparents', function () { mapShown[pk] = true; redraw(); }) - : U.veil('The grandparents of ' + child + '’s stretches stay hidden while a result to act on in adult life is ' + - 'closed: they could show which copy the child got there. Each stretch shows its parent only.', null, - function () { opened[openKey(child, shut[0])] = true; redraw(); })); - } + // One box for the whole painting: each stretch shows its parent only. + add(fig, veilBox(child, shut, 'paint', 'The grandparents of ' + child + '’s stretches', 'Show the grandparents')); 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'], @@ -474,15 +472,11 @@ if (rows && rows.length) { add(d, painting(f, c.child, rows)); } else if (c.segments) { - // The drawing as an image cannot close one stretch: while this child has a result to act on that is - // closed (or this page cannot tell), it stays closed whole. - var k = kids[c.child] || {}; - var all = k.status !== 'known' || (!k.spans && k.acts.length) ? [UNKNOWN] : (k.spans || []).map(function (s) { return s[0]; }); - var shut = drawingClosed(c.child, all, 'map'), mk = openKey(c.child, 'map'); - // "Show the map" opens the map only: each result keeps its own closed rows until it is opened. - if (shut.length && !mapShown[mk]) { - add(d, U.veil('The chromosome map of ' + c.child + ' stays closed while a result to act on in adult life is: ' + - 'it could show which copy the child got there.', 'Show the map', function () { mapShown[mk] = true; redraw(); })); + // The drawing as an image is coloured by grandparent too, and cannot close one stretch: it stays closed + // whole, as the painting does. + var shut = shutOf(c.child, 'map'); + if (shut.length) { + add(d, veilBox(c.child, shut, 'map', 'The chromosome map of ' + c.child + ' and its colours', 'Show the map')); } else { add(d, el('img', { src: c.segments, alt: 'Chromosome map of ' + c.child, 'class': 'segments' })); } @@ -492,24 +486,31 @@ var dn = (f.de_novo || []).filter(function (r) { return r.child === c.child; }); add(d, el('h3', {}, 'De novo candidates')); if (dn.length) { - var dv = veiled(c.child, dn, function (r) { return tagOf(c.child, r.chrom + ':' + r.pos + ' ' + r.ref + '>' + r.alt, r.chrom, r.pos, null, true); }); 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: dv.open.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)); - add(d, veilBox(c.child, dv.closed, 'row', 'rows')); + // Every row closes while a result is: a row closed on its own would say where the result lies. + var dshut = shutOf(c.child, 'denovo'); + if (dshut.length) { + add(d, veilBox(c.child, dshut, 'denovo', 'The de novo candidates of ' + c.child, 'Show the rows')); + } else { + 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', 'data-trio': 'de_novo' }, empty(c.child, 'de_novo', 'de_novo.tsv'))); } var ch = (f.compound_het || []).filter(function (r) { return r.child === c.child; }); add(d, el('h3', {}, 'Compound-het pairs')); if (ch.length) { - var cv = veiled(c.child, ch, function (r) { return tagOf(c.child, [r.gene, r.variant_a, r.variant_b].join(' ')); }); 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: cv.open.map(function (r) { return [r.gene, r.phase, r.variant_a, r.a_from, r.variant_b, r.b_from]; }) }, 8)); - add(d, veilBox(c.child, cv.closed, 'row', 'rows')); + var hshut = shutOf(c.child, 'het'); + if (hshut.length) { + add(d, veilBox(c.child, hshut, 'het', 'The compound-het pairs of ' + c.child, 'Show the rows')); + } else { + 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', 'data-trio': 'compound_het' }, empty(c.child, 'compound_het', 'compound_het.tsv'))); } @@ -525,26 +526,18 @@ 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.')); - // A child's value that names a closed result (a CPIC gene that is also the gene of a result to act on, RYR1 or - // CACNA1S) closes its whole row, as the child's own page closes that row of its medicines card: a closed cell - // under the gene's name would say which gene it is. While this page cannot read a child's results, every row - // that names a gene (the medicines and HLA rows) stays closed; the haplogroups and blood groups name none. - var kidsHere = (f.state.children || []).map(function (c) { return c.child; }); - var closedBy = {}, shown = []; - sb.rows.forEach(function (r) { - var open = true; - sb.members.forEach(function (m, i) { - if (kidsHere.indexOf(m) < 0) { return; } - var t = stillClosed(m, tagOf(m, r.label + ' ' + (r.values[i] || ''))); - if (t[0] === UNKNOWN && !GENE_GROUPS[r.group]) { return; } - if (t.length) { (closedBy[m] = closedBy[m] || []).push(t); open = false; } - }); - if (open) { shown.push(r); } - }); + // A child's medicines and HLA cells name genes (a CPIC gene can be the gene of a result to act on, RYR1 or + // CACNA1S). Closing only the row of a closed result's gene would leave a gap in a list everyone knows, so + // while a child has any result still closed every medicines and HLA cell of the child's column reads "closed": + // every row stays, with the parents' values. The haplogroups and blood groups name no gene. + var shutBy = {}; + sb.members.forEach(function (m) { if (isChild(f, m)) { shutBy[m] = shutOf(m, 'side'); } }); // A value from a section older than its input (state "stale") says so in its own cell. - var group = null, rows = [], old = 0; - shown.forEach(function (r) { + var group = null, rows = [], old = 0, closedCells = {}; + sb.rows.forEach(function (r) { rows.push([r.group === group ? '' : r.group, r.label].concat(r.values.map(function (v, i) { + var m = sb.members[i]; + if (GENE_GROUPS[r.group] && shutBy[m] && shutBy[m].length) { closedCells[m] = true; return 'closed'; } if (!(r.stale || [])[i]) { return v; } old++; return v + STALE; @@ -553,8 +546,7 @@ }); add(s, U.table({ columns: ['', 'Gene or system'].concat(sb.members), rows: rows }, 14)); sb.members.forEach(function (m) { - var box = closedBy[m] && veilBox(m, closedBy[m], 'row', 'rows'); - if (box) { box.setAttribute('data-veil-child', m); add(s, box); } + if (closedCells[m]) { add(s, veilBox(m, shutBy[m], 'side', 'The medicines and HLA cells of ' + m, 'Show the cells')); } }); if (old) { add(s, el('p', { 'class': 'dim', 'data-stale': String(old) }, 'Marked' + STALE + ': the member’s result file is from an ' + @@ -572,7 +564,12 @@ } if (f.state.reports.length) { var ul = el('ul', { 'class': 'lines' }); - f.state.reports.forEach(function (r) { add(ul, add(el('li', {}), el('a', { href: r.url }, r.name))); }); + // A couple report of a screen that stays closed is left out with it. + var shutReports = (f.state.couples || []).filter(function (c) { return c.report && coupleShut(f, c).length; }) + .map(function (c) { return c.report; }); + f.state.reports.forEach(function (r) { + if (shutReports.indexOf(r.url) < 0) { add(ul, add(el('li', {}), el('a', { href: r.url }, r.name))); } + }); add(s, ul); } return s; diff --git a/bin/live_results.py b/bin/live_results.py index a2202011f..3f0a3a603 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -1211,10 +1211,9 @@ def veil(summary, found, chapters, lead_parts=()): id}), every other finding that names a closed result (findings: finding id -> id; a symptom ranking can rank the gene) and the lead's segments that name one (lead: one per segment of LEAD_PARTS, the "act" names left out: the page closes those itself), each card's facts (facts: card -> one per fact) and the items - of its drawing (widget: card -> one per CHIP call or copy-number dot). spans is closable() itself, so the - family page (assets/live/family-page.js) can tag its own rows of a child, the de novo and compound-het - candidates, by this rule (core.js veilId(), checked against veil_id() under node). Empty when nothing can - be closed.""" + of its drawing (widget: card -> one per CHIP call or copy-number dot). spans is closable() itself: the family + page (assets/live/family-page.js) reads which results are closed from it, and closes every place of the child + that could carry one while one is. Empty when nothing can be closed.""" spans = closable(found) if not spans: return {} diff --git a/docs/live-site.md b/docs/live-site.md index d2f6ecf85..89d07f916 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -72,7 +72,7 @@ The header search finds a gene or rsID across the results and opens the card tha **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//`). 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page: a de novo candidate or a compound-het pair that names one stays closed behind "Show the result" until that result is opened on this page, and a count says how many rows stay closed. A stretch painted from a grandparent over one says which grandparent's copy the child got there: while such a stretch exists, every stretch of that child is drawn and listed from its parent alone, with no grandparent, so the painting does not point at where the result lies, and "Show the result" brings the grandparents back. Each click opens one result, a row that names two closed results stays closed until both are opened, and a reload closes them again. Opening a result opens it for that child only. A result known by its gene alone, with no position, could sit at any de novo candidate or painted stretch, which name no gene: those rows stay closed with it. When the stretches come only as the drawn image, the image stays closed while a result is, and "Show the map" opens the image and no result. The page reads each child's `results.json` to know which results those are; while it cannot (no run here, no answer yet, a request that failed, or results the server could not build), every such row of that child stays closed, and "Show the rows" opens them on purpose. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. A row whose value in a child's column names a closed result of that child (a CPIC gene such as RYR1 that is also the gene of a result to act on) is left out whole, for every member, until that result is opened here: a closed cell under the gene's name would say which gene it is. While the page cannot read a child's results, every medicines and HLA row stays closed; the haplogroups and blood groups name no gene. 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page, and what the page shows of a child never depends on which result is closed or where it lies. While a child has any result to act on still closed, every place of the page that could carry one closes for that child in the same way, each behind one box with the same words whatever the result: the grandparents of the painted stretches (every stretch is then drawn and listed from its parent alone), the chromosome map when it comes only as an image, every de novo candidate and every compound-het pair, the child's cell in every medicines and HLA row of the side by side, and the carrier screen of a couple the child is part of (its counts, its boxes, its table, the lead's words on it and its report link). Each "Show the result" opens one result, from whichever box it is clicked; the places come back once the child's last closed result is opened, and a reload closes them again. Opening a result opens it for that child only. The page reads each child's `results.json` to know whether a result is closed; while it cannot (no run here, no answer yet, a request that failed, results the server could not build, or results with findings to act on but no `veil.spans`), every place of that child stays closed, and each box opens only its own place on purpose: "Show the rows" its table, "Show the cells" the side by side, "Show the grandparents" the painting, "Show the map" the image and "Show the screen" the couple screen. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. Every row stays, with the parents' values: a child's medicines and HLA cells read "closed" while chapter IV's rule closes them (a row left out would leave a gap in a list everyone knows), and the haplogroups and blood groups, which name no gene, stay open. On a phone the grid becomes a list of pairs. 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); under 700 px the report is short: the findings of sections 1 to 4 (to act on, medicines, family, worth a look) stay open, while the cards behind them, every information card and the bodies of sections 5 to 10 start closed, each behind one line that says what it holds (section 5 stays open when it holds a finding about data quality, or a card the report flags with no finding of its own). On a child's page, and while the family's roles are not known, the findings to act on stay behind "Show the result" on a phone too. A fold you open stays open when the page is drawn again, the phone's folds too when you turn the phone to a wide screen and back. The counts read one line per level, and the "How to read this" note and the links between findings are left out. A link to a card, a section or a finding, a search hit and a tab open the folds around it (a Ctrl, Cmd or Shift click leaves this page as it is); 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. `tests/demo/render_pictures.py --out DIR --only live-phone` draws the picture below from the demo. @@ -88,7 +88,7 @@ The reads cross the network to the browser that logged in: only the byte ranges `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; a finding that names a result to act on, such as a symptom ranking of its gene, is never among them), `lead` (its lead paragraph as plain text), `lead_closed` (the same with the names of the findings to act on left out, and every other part that names one, 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`, `databases` and `veil` (on a child's page, the rows outside a result's own card that close with it, and `spans`, the closed results with their gene and position, which the family page tags a child's rows with by the same rule). 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`, `databases` and `veil` (on a child's page, the rows outside a result's own card that close with it, and `spans`, the closed results with their gene and position, from which the family page knows whether a child has a result still closed). 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/demo/make_demo_sample.py b/tests/demo/make_demo_sample.py index 352eb0b93..ab1b0be1e 100755 --- a/tests/demo/make_demo_sample.py +++ b/tests/demo/make_demo_sample.py @@ -108,7 +108,7 @@ def own_chroms(): # The son's results to act on ("act"): a pathogenic ClinVar hit in KCNH2, an ACMG secondary-findings gene that # is on no carrier list, and an AnnotSV class 5 deletion over it. Public gene name, invented positions and # alleles. tests/test_live_demo.py checks that his page names neither until each is opened on purpose, and that the -# family page keeps closed the compound-het pair in KCNH2 of tests/fixtures/live/demo/DEMO-FAMILY too. +# family page keeps his places closed with them, the compound-het pairs of tests/fixtures/live/demo/DEMO-FAMILY too. PLANTED = {"gene": "KCNH2", "hit": ("chr7", 150960000, "C", "T"), "sv": ("chr7", 150955000, 150965000)} # 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), diff --git a/tests/fixtures/live/demo/README.md b/tests/fixtures/live/demo/README.md index d08c2eade..a78ec4866 100644 --- a/tests/fixtures/live/demo/README.md +++ b/tests/fixtures/live/demo/README.md @@ -15,6 +15,6 @@ sample DEMO-001 that `tests/demo/make_demo_sample.py` writes. No real sample, pe `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. `compound_het.tsv` also holds a pair in KCNH2 whose first variant is the son's planted result to act on - (`make_demo_sample.py` PLANTED): the family page keeps that row closed until the result is opened there. + (`make_demo_sample.py` PLANTED): the family page keeps the son's pairs closed until his results are opened there. The run itself is `../demo-001.weblog.jsonl`, written by `tests/demo/make_demo_events.py`. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 968eeaaa9..ca4f4fb45 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -53,24 +53,26 @@ marks and ranking only, and a reload closes them again; the home page and every finding page of the son (an open finding's rail and related list too) name them nowhere, and the symptom ranking's own page shows the gene - once opened on purpose; on the family page the son's rows that name a planted - result (the compound-het pair in KCNH2 of the fixture, and a de novo - candidate and a stretch painted from a grandparent planted in family.json) - name its gene, positions and that grandparent in no row, link, title or - count until "Show the result" is clicked there, a row that names both - planted results stays closed until both are opened (one per click), each - row then comes back, and a reload closes them again; while a stretch painted - from a grandparent lies over one, every stretch of his is drawn and listed from - its parent alone, none marked; with a closed result of his in RYR1 the side by - side's RYR1 row stays closed until opened there; with the son's results.json - unavailable, or answered as unreadable, every row of his that could name one - stays closed (in the side by side every medicines and HLA row), and "Show the - rows" opens neither his painting's grandparents nor his map as an image, which - open only by their own button; with a closed result known by its gene alone his de novo rows - (a position, no gene) stay closed with it; his map as an image stays closed, - and "Show the map" opens the map and no result; on his page - a CHIP call and a copy-number dot in the gene of a closed result stay out - of their drawings until it is opened; DEMO-001's page + once opened on purpose; on the family page, while the son has a planted + result closed (the fixture's compound-het pair in KCNH2, a de novo + candidate and a stretch painted from a grandparent planted in family.json), + his painting's grandparents, his de novo and compound-het rows and his + medicines and HLA cells of the side by side close, one box each, and no + text or attribute names its gene, positions or that grandparent; each click + opens one result, every place comes back once both are opened, and a reload + closes them again; the page is the same, byte for byte, whichever result is + closed (KCNH2, RYR1, HLA-B, a gene alone, a position) and wherever a + grandparent's stretch lies; while the page cannot read his results (no + results.json, an unreadable one, act findings without veil.spans) every + place stays closed and each box opens only its own place; with a second + child holding the same results, opening his leaves hers closed; a couple + screen whose partner is a child with a closed result closes whole (counts, + table, boxes, report link, the lead's words); his map as an image stays + closed until each result is opened from its box, and with his results + unreadable "Show the rows" leaves it closed and "Show the map" opens it; on + his page a CHIP call, a copy-number dot and a card's fact in the gene of a + closed result stay out of sight (on a phone the card's line too) until it + is opened; DEMO-001's page mid-replay and at the end leads with the report (a short run band above the lead while it runs, none once finished), ends with the step timeline folded, keeps the wire to at most eight one-sentence lines with the plain @@ -81,10 +83,13 @@ the veil, person.js and finding.js that read a missing state as a standalone adult, and search.js without its filter; for the planted results, results.json without its veil tags, person.js whose table rows ignore them, person.js whose findings ignore them, charts.js that keeps every mark, search.js that ignores a hit's tag -and finding.js whose findings ignore their tags; for the family page, family-page.js whose rows ignore the veil -core.js whose veilId() tags nothing, family-page.js without the gene-only fallback, a "Show the map" that opens -the results too, a closed painting that keeps each stretch's grandparent, a side by side that ignores the veil and a -drawing that "Show the rows" opens; for the drawings, person.js that draws every item), the phone page (person.js that never folds, and a route that opens only the nearest fold), the +and finding.js whose findings ignore their tags; for the family page, de novo rows, compound-het rows, a painting, +a side by side's cells, a couple screen and a map image that ignore the veil, a closed painting that keeps each +stretch's grandparent, a box that opens every result with one click, de novo rows closed only when a result lies +under one, a painting closed only when a grandparent's stretch lies over a closed result, a side by side that closes +only the closed gene's cells, one opening shared by every box while the results cannot be read, results without +veil.spans read as nothing closed and an opened result kept per run only; for the son's page, person.js that draws +every item, shows every fact or builds a card's line from every fact), the phone page (person.js that never folds, and a route that opens only the nearest fold), the folds (a flagged section 5 card folded, a redraw that forgets the folds, a Ctrl+click that opens one) and the person page's layout (a wire showing every line or every plain finish, a card showing every line, a step timeline left open) also run against a planted mistake and must @@ -430,8 +435,13 @@ 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' && " - "(document.documentElement.dataset.pgpKids || '').indexOf('DEMO-003:known') >= 0 && JSON.stringify([" + b.wait("document.documentElement.dataset.pgpStatus === 'ready' && " + "(document.documentElement.dataset.pgpKids || '').indexOf('DEMO-003:known') >= 0") + # The son's results to act on close his tables here: open each on purpose (one per click) to count them. + for _ in range(4): + b.eval("var v = document.querySelector('.veil[data-veil-child=\"DEMO-003\"] button'); v && v.click(); true") + time.sleep(0.2) + return b.wait("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, " @@ -739,12 +749,16 @@ def write(p, text): write(p, text) -# The family page: the son's rows that name his planted result to act on. -FAMILY_SHOWN_JS = ("[document.body.textContent].concat(Array.prototype.map.call(document.querySelectorAll('[href],[title],[aria-label]," - "[alt]'), function (e) { return ['href', 'title', 'aria-label', 'alt'].map(function (k) { " - "return e.getAttribute(k) || ''; }).join(' '); })).join(' ')") +# The family page: what it shows of the son while a result of his to act on is closed. +# Every text of the page and every attribute of every element: a gene, a position or a grandparent hidden in a +# title, a link, a label, a data attribute or a class is as shown as one in a cell. +FAMILY_SHOWN_JS = ("[document.body.textContent].concat(Array.prototype.map.call(document.querySelectorAll('*'), function (e) { " + "return Array.prototype.map.call(e.attributes, function (a) { return a.value; }).join(' '); })).join(' ')") GRANDPARENT = "DEMO-GF" PAIR_B = "150962400" # the second variant of the fixture's compound-het pair in KCNH2 +SON = OTHERS[1] +# The places that close for the son while a result of his is closed, one box each, in page order. +SON_BOXES = ["paint", "denovo", "het", "side"] def no_cache(b): @@ -764,302 +778,405 @@ def family_page(b, base): time.sleep(0.3) +def boxes_of(b, child=SON): + """The places closed for CHILD (data-veil-box of each of its closed boxes), in page order.""" + got = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('.veil[data-veil-child=\"" + child + + "\"]'), function (x) { return x.getAttribute('data-veil-box'); }))") + return json.loads(got) if got else None + + +def open_box(b, box, child=SON): + """Click the button of CHILD's closed box BOX; whether there was one.""" + ok = b.eval("var v = document.querySelector('.veil[data-veil-child=\"" + child + "\"][data-veil-box=\"" + box + "\"] button'); " + "if (v) { v.click(); } !!v") + time.sleep(0.4) + return ok + + +def son_tables(b, child=SON): + """[rows of each table] in CHILD's fold of the family page.""" + rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"" + child + "\"] " + "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") + return json.loads(rows) if rows else None + + +def side_rows(b): + """The side by side as [[group, label, value of each member]], the group carried down.""" + got = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('#ch-side-by-side table.rows tbody tr'), " + "function (r) { return Array.prototype.map.call(r.cells, function (c) { return c.textContent; }); }))") + rows, group = [], "" + for r in json.loads(got) if got else []: + group = r[0] or group + rows.append([group] + r[1:]) + return rows + + +def side_problems_of(b, closed): + """[problems] of the side by side: the son's medicines and HLA cells all "closed" when CLOSED, none otherwise; a + parent's cell never closed, the haplogroups and blood groups never closed.""" + rows = side_rows(b) + members = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('#ch-side-by-side table.rows thead th'), " + "function (x) { return x.textContent; }).slice(2))") + members = json.loads(members) if members else [] + if SON not in members or not rows: + return [f"no side by side with the son: {members}"] + out = [] + for r in rows: + for m, v in zip(members, r[2:]): + want = closed and m == SON and r[0] in ("Medicines", "HLA") + if (v == "closed") != want: + out.append(f"{m}'s {r[1]} reads {v!r}") + return out[:6] + + def family_closed(b, base): """[problems] of the family page before any click: the son's planted gene, its positions or the grandparent of - the stretch over it named anywhere; the rows that name nothing closed gone too.""" + the stretch over it named anywhere, or a place of his open: the painting's grandparents, his de novo and + compound-het rows and his medicines and HLA cells close, one box each.""" gene, hit, sv = planted_words() family_page(b, base) shown = b.eval(FAMILY_SHOWN_JS) or "" out = [f"{w} shown on the family page while closed" for w in [gene, GRANDPARENT, PAIR_B] + hit + sv[:1] if w in shown] - rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " - "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") - closed = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") + boxes, tables = boxes_of(b), son_tables(b) segs = b.eval("document.querySelectorAll('[data-painting=\"DEMO-003\"] .paint-seg').length") marked = b.eval("document.querySelectorAll('[data-painting=\"DEMO-003\"] .paint-seg.closed, " "[data-painting=\"DEMO-003\"] .paint-seg.other').length") - # The 2 de novo rows of the fixture and its 2 other pairs stay, one closed box each; the painting closes whole - # (one box): every stretch drawn and listed from its parent, none marked, so none points at the closed result. - if not rows or json.loads(rows)[1:] != [2, 2] or closed != 3 or json.loads(rows)[0] != segs or marked: - out.append(f"the open rows and the closed boxes: tables {rows}, {closed} closed boxes, {segs} stretches drawn, " - f"{marked} marked") + # Only the painting's table of stretches stays, every stretch drawn and listed from its parent, none marked. + if boxes != SON_BOXES or not tables or len(tables) != 1 or tables[0] != segs or marked: + out.append(f"closed places {boxes}, tables {tables}, {segs} stretches drawn, {marked} marked") + out += side_problems_of(b, True) return out def family_problems(b, base): - """[problems] of the family page: closed, then opened one result per click, then reloaded. Each planted row - names both of the son's planted results (the secondary finding's position, and the deletion's span around it, - both in KCNH2), so the first click opens one result and every row stays closed for the other.""" + """[problems] of the family page: closed; one click on the compound-het box opens one of the son's two planted + results and leaves every place closed; a click on the de novo box opens the other and every place; a reload + closes them again.""" gene, hit, _ = planted_words() out = family_closed(b, base) if out: return out - click = "document.querySelector('details[data-child=\"DEMO-003\"] .veil button').click(); true" - b.eval(click) - time.sleep(0.5) + if not open_box(b, "het"): + return ["no compound-het box"] shown = b.eval(FAMILY_SHOWN_JS) or "" - left = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") - if any(w in shown for w in [gene, GRANDPARENT, PAIR_B] + hit) or left != 3: - out.append(f"one click opened a row that also names the other closed result ({left} closed boxes left)") - b.eval(click) - time.sleep(0.5) + if any(w in shown for w in [gene, GRANDPARENT, PAIR_B] + hit) or boxes_of(b) != SON_BOXES: + out.append(f"one click opened a place while the other result is still closed: {boxes_of(b)}") + open_box(b, "denovo") shown = b.eval(FAMILY_SHOWN_JS) or "" - rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " - "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") - left = b.eval("document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil').length") - if not all(w in shown for w in [gene, GRANDPARENT, hit[0]]) or json.loads(rows or "[0,0,0]")[1:] != [3, 3] or left: - out.append(f"two clicks on 'Show the result' did not bring back each row: tables {rows}, {left} closed boxes left") + tables = son_tables(b) + if not all(w in shown for w in [gene, GRANDPARENT, hit[0], PAIR_B]) or not tables or tables[1:] != [3, 3] or boxes_of(b): + out.append(f"opening both results did not bring back every place: tables {tables}, closed {boxes_of(b)}") + out += side_problems_of(b, False) out += [f"after a reload: {x}" for x in family_closed(b, base)] return out -def family_unknown(b, base): - """[problems] of the family page with the son's results.json unavailable: a row of his named, or any of his de - novo or compound-het rows open.""" +def family_view(b, base): + """The family page's HTML before any click.""" + family_page(b, base) + return b.eval("document.getElementById('family').innerHTML") or "" + + +def invariance_problems(b, base, rj, res, fj, fams): + """[problems]: the family page before any click differs between closed results of different genes and places + (FAMS: name -> family.json text; the results' spans set in RJ from RES), so it would tell which is closed.""" + views = {} + for name, spans in (("the planted pair in KCNH2", None), ("RYR1, a medicines gene", [["sf-ryr1", "RYR1", "chr19", 38440000, 38440000]]), + ("HLA-B", [["sf-hlab", "HLA-B", "chr6", 31353000, 31357000]]), + ("a gene alone", [["two-hits-g0999", "G0999", None, None, None]]), + ("a position under a de novo row", [["sf-pos", None, "chr3", 48211904, 48211904]])): + r = copy.deepcopy(res) + if spans is not None: + r["veil"]["spans"] = spans + with open(rj, "w") as f: + json.dump(r, f) + for fname, text in fams.items(): + with open(fj, "w") as f: + f.write(text) + views[f"{name}, {fname}"] = family_view(b, base) + with open(rj, "w") as f: + json.dump(res, f) + first = next(iter(views)) + return [f"the page differs between {first!r} and {k!r}" for k, v in views.items() if v != views[first]] + + +def family_unknown(b, base, opens=True): + """[problems] of the family page while it cannot read the son's results: a place of his open or a word of his + planted results shown; then (OPENS) the de novo box's own button opens his de novo rows and no other place.""" gene, hit, sv = planted_words() family_page(b, base) shown = b.eval(FAMILY_SHOWN_JS) or "" - out = [f"{w} shown with the son's results unavailable" for w in [gene, GRANDPARENT, PAIR_B] + hit + sv[:1] if w in shown] - rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " - "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") - if rows is None or len(json.loads(rows)) != 1: - out.append(f"the son's de novo or compound-het rows open with his results unavailable: tables {rows}") - side = side_labels(b) - if any(g in side for g in SIDE_GENES) or "Mitochondrial" not in side: - out.append(f"side by side with the son's results unavailable: rows {sorted(side)[:8]}") + out = [f"{w} shown with the son's results unreadable" for w in [gene, GRANDPARENT, PAIR_B] + hit + sv[:1] if w in shown] + if boxes_of(b) != SON_BOXES or son_tables(b) is None or len(son_tables(b)) != 1: + out.append(f"closed places {boxes_of(b)}, tables {son_tables(b)}") + words = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('.veil[data-veil-child=\"DEMO-003\"] button'), " + "function (x) { return x.textContent; }))") + if json.loads(words or "[]") != ["Show the grandparents", "Show the rows", "Show the rows", "Show the cells"]: + out.append(f"the buttons read {words}") + out += side_problems_of(b, True) + if not opens or out: + return out + open_box(b, "denovo") + shown = b.eval(FAMILY_SHOWN_JS) or "" + if boxes_of(b) != ["paint", "het", "side"] or (son_tables(b) or [])[1:] != [3] or GRANDPARENT in shown or PAIR_B in shown: + out.append(f"the de novo box opened another place: closed {boxes_of(b)}, tables {son_tables(b)}") + out += side_problems_of(b, True) return out -SIDE_GENES = ("RYR1", "CACNA1S", "CYP2D6", "HLA-A") - - -def click_veil(b, words): - """Click the son's closed box whose button says WORDS; whether there was one.""" - return b.eval("var v = Array.prototype.filter.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil " - "button'), function (x) { return x.textContent === " + json.dumps(words) + "; })[0]; " - "if (v) { v.click(); } !!v") - - -def unknown_drawing_problems(b, base, image): - """[problems] of the family page with the son's results unavailable: "Show the rows" of a table opening his - drawing (the map as an image, or the painting's grandparents), or the drawing's own button not opening it.""" +def unknown_map_problems(b, base): + """[problems] of the family page whose son has his map as an image, with his results unreadable: 'Show the + rows' of a table opening the image, or 'Show the map' not opening it.""" family_page(b, base) - out = [] - if not click_veil(b, "Show the rows"): - out.append("no 'Show the rows' box") - time.sleep(0.4) img = "!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')" - seen = (lambda: b.eval(img)) if image else (lambda: GRANDPARENT in (b.eval(FAMILY_SHOWN_JS) or "")) - if seen(): - out.append("'Show the rows' opened the drawing too") - if not click_veil(b, "Show the map" if image else "Show the grandparents"): - out.append("no closed box of the drawing") - time.sleep(0.4) - if not seen(): - out.append("the drawing's own button did not open it") + out = [] + if not open_box(b, "denovo"): + out.append("no de novo box") + if b.eval(img): + out.append("'Show the rows' opened the map too") + if not open_box(b, "map") or not b.eval(img): + out.append("'Show the map' did not open the map") return out -def side_labels(b): - """The 'Gene or system' column of the family page's side by side.""" - got = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('#ch-side-by-side table.rows tbody tr'), " - "function (r) { return r.children[1] ? r.children[1].textContent : ''; }))") - return json.loads(got) if got else [] - - -def side_problems(b, base): - """[problems] of the family page's side by side with a closed result of the son's in RYR1: the RYR1 row (his - diplotype and phenotype under the gene's name) shown before it is opened there, the other rows closed with it, - or the row not back once opened.""" +def map_problems(b, base): + """[problems] of the family page whose son has his map as an image and two closed results: the image shown + before both are opened from its box (one per click), or not shown after.""" family_page(b, base) + img = "!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')" out = [] - labels = side_labels(b) - shown = b.eval("document.getElementById('ch-side-by-side').textContent") or "" - boxes = b.eval("document.querySelectorAll('#ch-side-by-side .veil[data-veil-child=\"DEMO-003\"]').length") - if "RYR1" in shown or "CACNA1S" not in labels or boxes != 1: - out.append(f"before any click: RYR1 {'shown' if 'RYR1' in shown else 'closed'}, {len(labels)} rows, {boxes} closed boxes") - b.eval("var x = document.querySelector('#ch-side-by-side .veil button'); x && x.click(); true") - time.sleep(0.4) - labels2 = side_labels(b) - if "RYR1" not in labels2 or len(labels2) != len(labels) + 1: - out.append(f"after 'Show the result': {len(labels2)} rows, RYR1 {'back' if 'RYR1' in labels2 else 'still closed'}") + if b.eval(img) or boxes_of(b) != ["map", "denovo", "het", "side"]: + out.append(f"the map shown while a result is closed, or a place open: {boxes_of(b)}") + open_box(b, "map") + if b.eval(img) or boxes_of(b) != ["map", "denovo", "het", "side"]: + out.append(f"one click opened a place while the other result is closed: {boxes_of(b)}") + open_box(b, "map") + if not b.eval(img) or boxes_of(b): + out.append(f"the map not back once both results are opened: closed {boxes_of(b)}") return out -def son_tables(b): - """[rows of each table] in the son's fold of the family page.""" - rows = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] " - "table.rows'), function (t) { return t.querySelectorAll('tbody tr').length; }))") - return json.loads(rows) if rows else None - - -def gene_only_problems(b, base): - """[problems] of the family page when the son also has a closed result known by its gene alone: a de novo row - shown before it is opened, a pair in another gene closed, or the rows not back once each result is opened.""" +def two_children_problems(b, base): + """[problems] of a family with a second child, DEMO-004, whose run has the son's results under the same ids: + opening every result of the son leaves each of DEMO-004's places closed.""" family_page(b, base) out = [] - t = son_tables(b) - shown = b.eval(FAMILY_SHOWN_JS) or "" - # Open: the stretches' table and the two pairs in other genes; closed: both de novo rows. - if t != [80, 2] or "48211904" in shown: - out.append(f"before any click: tables {t}") - for _ in range(3): # the gene-only result, then the two planted ones (the pair in KCNH2) - b.eval("var x = document.querySelector('details[data-child=\"DEMO-003\"] .veil button'); x && x.click(); true") - time.sleep(0.4) - t = son_tables(b) - if t != [80, 2, 3]: - out.append(f"after opening each result: tables {t}") + if boxes_of(b, "DEMO-004") != SON_BOXES: + out.append(f"DEMO-004's closed places before: {boxes_of(b, 'DEMO-004')}") + for _ in range(2): + open_box(b, "denovo") + if boxes_of(b): + out.append(f"the son's places not opened: {boxes_of(b)}") + other = b.eval("document.querySelector('details[data-child=\"DEMO-004\"]').textContent") or "" + if boxes_of(b, "DEMO-004") != SON_BOXES or GRANDPARENT in other or planted_words()[1][0] in other: + out.append(f"opening the son's results opened DEMO-004's: {boxes_of(b, 'DEMO-004')}") return out -def map_problems(b, base): - """[problems] of the family page whose son has his map as an image: the image shown while a result is closed, - or 'Show the map' opening a result's rows.""" +def couple_problems(b, base): + """[problems] of a family where DEMO-001, a partner of the couple screen, is also a child (of an unsequenced + father) with a closed result in PRF1, a gene of the screen: the screen names it (its line's counts, its + table, its boxes, its report link, the lead) before the result is opened from its box, or not after.""" family_page(b, base) out = [] - img = b.eval("!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')") - if img: - out.append("the map shown while a result is closed") - b.eval("var v = Array.prototype.filter.call(document.querySelectorAll('details[data-child=\"DEMO-003\"] .veil'), " - "function (x) { return x.textContent.indexOf('Show the map') >= 0; })[0]; v && v.querySelector('button').click(); true") - time.sleep(0.4) - img = b.eval("!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')") - shown = b.eval(FAMILY_SHOWN_JS) or "" - if not img: - out.append("'Show the map' did not show the map") - if "KCNH2" in shown or PAIR_B in shown: - out.append("'Show the map' opened the closed pair too") + sect = b.eval("var s = document.getElementById('ch-couples'); s ? s.textContent + ' ' + s.innerHTML : ''") or "" + lead = b.eval("(document.getElementById('family-lead') || {}).textContent || ''") or "" + links = b.eval("Array.prototype.map.call(document.querySelectorAll('a[href]'), function (a) { return a.getAttribute('href'); }).join(' ')") or "" + bx = boxes_of(b, "DEMO-001") or [] + if ("PRF1" in sect or "70601223" in sect or "CPT2" in sect or "couple_DEMO" in links + or "carrier screen stays closed in chapter III" not in lead): + out.append("the screen of a couple whose partner has a closed result named before it is opened") + if bx != ["couple:DEMO-001 DEMO-002", "side"]: + out.append(f"DEMO-001's closed places: {bx}") + if not open_box(b, "couple:DEMO-001 DEMO-002", "DEMO-001"): + return out + ["no closed box under the screen"] + sect = b.eval("document.getElementById('ch-couples').textContent") or "" + links = b.eval("Array.prototype.map.call(document.querySelectorAll('a[href]'), function (a) { return a.getAttribute('href'); }).join(' ')") or "" + if "PRF1" not in sect or "couple_DEMO" not in links or boxes_of(b, "DEMO-001"): + out.append(f"the screen not back once the result is opened: closed {boxes_of(b, 'DEMO-001')}") return out def family_veil_checks(b, base, out): - """On the family page a child's rows that name a closed result stay closed as on the child's page, until that - result is opened there; a page that cannot read the child's results keeps every row of his that could closed.""" + """On the family page, while a child has a result to act on still closed, or the page cannot read the child's + results, every place that could carry one closes for that child, the same whichever result it is; each known + result opens with its own click, and each place opens on its own while the results cannot be read.""" no_cache(b) import make_demo_sample fj = os.path.join(out, "runs", FAM, "family.json") - rj = os.path.join(out, "runs", OTHERS[1], "results.json") - fp, cj = (os.path.join(out, "assets", x) for x in ("family-page.js", "core.js")) + rj = os.path.join(out, "runs", SON, "results.json") + rj1 = os.path.join(out, "runs", S, "results.json") + fp = os.path.join(out, "assets", "family-page.js") keep = {} - for p in (fj, fp, cj): + for p in (fj, fp, rj, rj1): with open(p) as f: keep[p] = f.read() def write(p, text): with open(p, "w") as f: f.write(text) + + def planted_control(desc, a, z, problems): + """Run PROBLEMS against family-page.js with A replaced by Z: it must find a problem.""" + if a not in keep[fp]: + check(f"control: {desc} (the planted line is in family-page.js)", False, a) + return + write(fp, keep[fp].replace(a, z)) + try: + control(desc, not problems()) + finally: + write(fp, keep[fp]) try: # A de novo candidate at the planted hit, and the father's copy over it painted from a grandparent. fam = json.loads(keep[fj]) c, pos, ref, alt = make_demo_sample.PLANTED["hit"] - row = dict(fam["de_novo"][0], chrom=c, pos=str(pos), ref=ref, alt=alt) - fam["de_novo"].append(row) - segs = fam["segments"]["DEMO-003"] + fam["de_novo"].append(dict(fam["de_novo"][0], chrom=c, pos=str(pos), ref=ref, alt=alt)) + segs = fam["segments"][SON] over = [s_ for s_ in segs if s_["chrom"] == c and s_["side"] == "paternal" and int(s_["start"]) <= pos <= int(s_["end"])] - if len(over) != 1: - check("browser: the son's stretches hold one paternal stretch over the planted hit", False, over) + away = [s_ for s_ in segs if s_["chrom"] != c and s_["side"] == "paternal"] + if len(over) != 1 or not away: + check("browser: the son's stretches hold one paternal stretch over the planted hit, and others", False, over) return over[0]["ancestor"] = GRANDPARENT - write(fj, json.dumps(fam)) - keep["planted"] = json.dumps(fam) + planted = json.dumps(fam) + over[0]["ancestor"] = "." + away[0]["ancestor"] = GRANDPARENT + elsewhere = json.dumps(fam) + write(fj, planted) bad = family_problems(b, base) - check("browser: on the family page the son's rows that name his planted result (a compound-het pair, a de novo " - "candidate, a stretch from a grandparent) name its gene, positions and grandparent nowhere until 'Show the " - "result' is clicked there, a row naming two closed results stays closed until both are opened, then each " - "row comes back, and a reload closes them", not bad, bad) - for p, a, z, desc in ( - (fp, "return tags.filter(function (t) {", "return [].filter(function (t) {", - "family-page.js whose rows ignore the veil"), - (cj, " if (!spans || !spans.length) { return null; }", " return null;", "core.js whose veilId() tags nothing"), - (fp, "ancestor: '.', basis: 'parent' };", "ancestor: r.ancestor, basis: r.basis };", - "a closed painting that keeps each stretch's grandparent")): - if a not in keep[p]: - check(f"control: {desc} (the planted line is in {os.path.basename(p)})", False, a) - continue - write(p, keep[p].replace(a, z)) - control(desc, not family_closed(b, base)) - write(p, keep[p]) + check("browser: on the family page, while the son has a result to act on closed, his painting's grandparents, " + "his de novo and compound-het rows and his medicines and HLA cells close, one box each, naming no gene, " + "position or grandparent; each click opens one result, every place comes back once both are opened, and " + "a reload closes them", not bad, bad) + for desc, a, z in ( + ("de novo rows that ignore the veil", "var dshut = shutOf(c.child, 'denovo');", "var dshut = [];"), + ("compound-het rows that ignore the veil", "var hshut = shutOf(c.child, 'het');", "var hshut = [];"), + ("a painting that ignores the veil", "var shut = anc ? shutOf(child, 'paint') : [];", "var shut = [];"), + ("a closed painting that keeps each stretch's grandparent", "ancestor: '.', basis: 'parent' };", + "ancestor: r.ancestor, basis: r.basis };"), + ("a side by side whose cells ignore the veil", "closedCells[m] = true; return 'closed';", "closedCells[m] = true; return v;")): + planted_control(desc, a, z, lambda: family_closed(b, base)) + planted_control("a box that opens every result with one click", + "function () { opened[openKey(child, shut[0])] = true; redraw(); });", + "function () { shut.forEach(function (t) { opened[openKey(child, t)] = true; }); redraw(); });", + lambda: family_problems(b, base)) + + # The same page whichever result is closed and wherever it lies. + res = json.loads(keep[rj]) + fams = {"a grandparent over the hit": planted, "a grandparent elsewhere": elsewhere} + bad = invariance_problems(b, base, rj, res, fj, fams) + check("browser: the family page is the same, byte for byte, whichever of the son's results is closed (a pair " + "in KCNH2, RYR1, HLA-B, a gene alone, a position) and wherever a grandparent's stretch lies", not bad, bad) + for desc, a, z in ( + ("de novo rows closed only when a result lies under one", "var dshut = shutOf(c.child, 'denovo');", + "var dshut = shutOf(c.child, 'denovo').filter(function (id) { return dn.some(function (r) { return " + "kids[c.child].spans.some(function (s) { return s[0] === id && s[2] === r.chrom && +r.pos >= s[3] && " + "+r.pos <= s[4]; }); }); });"), + ("a painting closed only when a grandparent's stretch lies over a closed result", + "var anc = all.some(function (r) { return r.ancestor && r.ancestor !== '.'; });", + "var anc = all.some(function (r) { return r.ancestor && r.ancestor !== '.' && ((kids[child] || {}).spans || [])" + ".some(function (s) { return !s[2] || (s[2] === r.chrom && s[3] <= +r.end && s[4] >= +r.start); }); });"), + ("a side by side that closes only the closed gene's cells", + "if (GENE_GROUPS[r.group] && shutBy[m] && shutBy[m].length) {", + "if (GENE_GROUPS[r.group] && shutBy[m] && shutBy[m].length && (kids[m].spans || []).some(function (s) { " + "return s[1] === r.label; })) {")): + planted_control(desc, a, z, lambda: invariance_problems(b, base, rj, res, fj, fams)) + write(fj, planted) + + # The page cannot read the son's results: no answer, an unreadable answer, act findings without veil.spans. os.replace(rj, rj + ".away") try: bad = family_unknown(b, base) - bad += unknown_drawing_problems(b, base, False) + check("browser: with the son's results.json unavailable every place of his stays closed, and the de novo " + "box's own button opens his de novo rows and no other place", not bad, bad) + planted_control("one opening shared by every box while the results cannot be read", + "return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN];", + "return opened[openKey(child, 'box:denovo')] ? [] : [UNKNOWN];", + lambda: family_unknown(b, base)) finally: os.replace(rj + ".away", rj) - check("browser: with the son's results.json unavailable the family page keeps every row of his that could name " - "a result to act on closed", not bad, bad) - # The results route answers a summary it cannot build with a note and no findings (owngenome_live_results.py). - with open(rj) as f: - keep[rj] = f.read() - write(rj, json.dumps({"schema_version": 1, "sample": OTHERS[1], "chapters": [], + write(rj, json.dumps({"schema_version": 1, "sample": SON, "chapters": [], "note": "The results could not be read (KeyError)."})) - bad = family_unknown(b, base) - check("browser: with the son's results answered as unreadable (a note, no findings) the family page keeps every " - "row of his that could name a result to act on closed", not bad, bad) - - # A closed result known by its gene alone: a row with only a position (a de novo candidate) could be it. - res = json.loads(keep[rj]) - res["veil"]["spans"].append(["two-hits-g0999", "G0999", None, None, None]) - write(rj, json.dumps(res)) - write(fj, keep[fj]) # the fixture's family: two de novo rows, three pairs (one of them in KCNH2) - bad = gene_only_problems(b, base) - check("browser: with a closed result known by its gene alone, the son's de novo rows (a position, no gene) stay " - "closed until it is opened, and his pairs in other genes stay open", not bad, bad) - a = "if (coords) {" - if a not in keep[fp]: - check("control: family-page.js without the gene-only fallback (the planted line is in family-page.js)", False, a) - else: - write(fp, keep[fp].replace(a, "if (false) {")) - control("family-page.js without the gene-only fallback", not gene_only_problems(b, base)) - write(fp, keep[fp]) + bad = family_unknown(b, base, opens=False) + check("browser: with the son's results answered as unreadable (a note, no findings) every place of his stays " + "closed", not bad, bad) + nospans = json.loads(keep[rj]) + nospans.pop("veil", None) + write(rj, json.dumps(nospans)) + bad = family_unknown(b, base, opens=False) + check("browser: with the son's results holding findings to act on but no veil.spans every place of his stays " + "closed", not bad, bad) + planted_control("results without veil.spans read as nothing closed", + "if (!k || k.status !== 'known' || (!k.spans && k.acts.length)) {", + "if (!k || k.status !== 'known') {", lambda: family_unknown(b, base, opens=False)) write(rj, keep[rj]) - # A closed result in a CPIC gene: the side by side lists that gene with the son's diplotype in his column. - res = json.loads(keep[rj]) - res["veil"]["spans"].append(["sf-ryr1-demo", "RYR1", "chr19", 38440000, 38440000]) - write(rj, json.dumps(res)) - bad = side_problems(b, base) - check("browser: with a closed result of the son's in RYR1, the side by side's RYR1 row (his diplotype and " - "phenotype) stays closed until it is opened there, and the other rows stay", not bad, bad) - a = "var t = stillClosed(m, tagOf(m, r.label + ' ' + (r.values[i] || '')));" - if a not in keep[fp]: - check("control: a side by side that ignores the veil (the planted line is in family-page.js)", False, a) - else: - write(fp, keep[fp].replace(a, "var t = [];")) - control("a side by side that ignores the veil", not side_problems(b, base)) - write(fp, keep[fp]) - write(rj, keep[rj]) - - # The drawing as an image (no stretches in family.json): "Show the map" opens the map and no result. + # A second child whose run holds the same results under the same ids: opening is per child. + fam2 = json.loads(planted) + fam2["state"]["children"].append(dict(fam2["state"]["children"][0], child="DEMO-004")) + fam2["runs"]["DEMO-004"] = fam2["runs"][SON] + fam2["segments"]["DEMO-004"] = fam2["segments"][SON] + fam2["de_novo"] += [dict(r, child="DEMO-004") for r in fam2["de_novo"] if r["child"] == SON] + fam2["compound_het"] += [dict(r, child="DEMO-004") for r in fam2["compound_het"] if r["child"] == SON] + fam2["side_by_side"]["members"].append("DEMO-004") + for r in fam2["side_by_side"]["rows"]: + r["values"].append(r["values"][fam2["side_by_side"]["members"].index(SON)]) + if r.get("stale"): + r["stale"].append(False) + write(fj, json.dumps(fam2)) + bad = two_children_problems(b, base) + check("browser: with two children holding the same results, opening each of the son's leaves the other " + "child's places closed", not bad, bad) + planted_control("an opened result kept per run only, not per child", + "function openKey(child, id) { return (kids[child] && kids[child].url) + '|' + child + '|' + id; }", + "function openKey(child, id) { return (kids[child] && kids[child].url) + '|' + id; }", + lambda: two_children_problems(b, base)) + write(fj, planted) + + # A partner of the couple screen who is also a child, with a closed result in a gene of the screen. + famc = json.loads(keep[fj]) + famc["state"]["members"].insert(0, {"sample": GRANDPARENT, "sex": "male", "father": None, "mother": None, + "partner": None, "sequenced": False}) + for m in famc["state"]["members"]: + if m["sample"] == S: + m["father"] = GRANDPARENT + famc["state"]["children"].insert(0, {"child": S, "father": GRANDPARENT, "mother": None, "segments": None}) + rep = f"couple_{S}_{OTHERS[0]}.md" + famc["state"]["couples"][0]["report"] = rep + famc["state"]["reports"] = famc["state"].get("reports", []) + [{"name": rep, "url": rep}] + write(fj, json.dumps(famc)) + r1 = json.loads(keep[rj1]) + r1.setdefault("veil", {})["spans"] = [["two-hits-prf1", "PRF1", None, None, None]] + write(rj1, json.dumps(r1)) + bad = couple_problems(b, base) + check("browser: a couple screen whose partner is a child with a closed result stays closed whole (counts, " + "table, boxes, report link, the lead's words) until that result is opened from its box", not bad, bad) + planted_control("a couple screen that ignores the veil", "var cs = coupleShut(f, c);", "var cs = [];", + lambda: couple_problems(b, base)) + write(rj1, keep[rj1]) + + # The drawing as an image (no stretches in family.json). fam = json.loads(keep[fj]) fam["segments"] = {} fam["state"]["children"][0]["segments"] = fam["state"]["pedigree"] write(fj, json.dumps(fam)) bad = map_problems(b, base) - check("browser: the son's map as an image stays closed while a result is, and 'Show the map' opens the map " - "and leaves each result's rows closed", not bad, bad) + check("browser: the son's map as an image stays closed while a result is, and its box opens one result per " + "click, the map once both are opened", not bad, bad) + planted_control("a map image that ignores the veil", "var shut = shutOf(c.child, 'map');", "var shut = [];", + lambda: map_problems(b, base)) os.replace(rj, rj + ".away") try: - bad = unknown_drawing_problems(b, base, True) + bad = unknown_map_problems(b, base) check("browser: with the son's results unavailable, 'Show the rows' leaves his map as an image closed, and " "'Show the map' opens it", not bad, bad) - a = "return t === UNKNOWN ? !mapShown[openKey(child, key)] :" - if a not in keep[fp]: - check("control: a drawing that 'Show the rows' opens (the planted line is in family-page.js)", False, a) - else: - write(fp, keep[fp].replace(a, "return t === UNKNOWN ? !mapShown[openKey(child, key)] && !openedAll[child] :")) - control("a drawing that 'Show the rows' opens", not unknown_drawing_problems(b, base, True)) - write(fp, keep[fp]) + planted_control("a map image that 'Show the rows' opens", + "return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN];", + "return opened[openKey(child, 'box:denovo')] ? [] : [UNKNOWN];", + lambda: unknown_map_problems(b, base)) finally: os.replace(rj + ".away", rj) - a = "function () { mapShown[mk] = true; redraw(); }" - if a not in keep[fp]: - check("control: a 'Show the map' that opens the results too (the planted line is in family-page.js)", False, a) - else: - write(fp, keep[fp].replace(a, "function () { mapShown[mk] = true; shut.forEach(function (t) { " - "opened[openKey(c.child, t)] = true; }); redraw(); }")) - control("a 'Show the map' that opens the results too", not map_problems(b, base)) - write(fp, keep[fp]) finally: - keep.pop("planted", None) for p, text in keep.items(): write(p, text) @@ -1084,6 +1201,24 @@ def drawn(): "&& document.querySelector('[data-widget=copies]') && document.querySelector('.veil') && 'drawn'", 15) return shown() + def phone_line(): + """The copy-number card's one line on a phone (its fold's summary, after the card's own title, the same for + everyone), drawn from its first open fact.""" + b.call("Emulation.setDeviceMetricsOverride", {"width": 390, "height": 844, "deviceScaleFactor": 2, "mobile": False}, + b.session) + try: + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{OTHERS[1]}/") + return b.wait("document.documentElement.dataset.pgpStatus === 'ready' && document.documentElement.dataset.pgpResults " + "&& (document.querySelector('[data-card=fold-paralogs] summary .what') || {}).textContent", 15) or "" + finally: + b.call("Emulation.clearDeviceMetricsOverride", {}, b.session) + + def phone_problems(): + line = phone_line() + return [] if line and "SMN1" not in line else [f"the copy-number card's line on a phone: {line!r}"] + def shown(): return b.eval("JSON.stringify([" + SHOWN_JS + ", (document.querySelector('[data-widget=copies]') || {}).textContent || ''])") @@ -1095,6 +1230,9 @@ def problems(text, opened): if ("p.R175H" in page) != opened or ("SMN1" in cops) != opened: out_.append(f"the CHIP call or the SMN1 dot of the closed results {'not ' if opened else ''}shown: " f"{'p.R175H' in page} | {cops[:200]}") + # The copy-number card's facts name SMN1: closed in its margin and in its line of section 8. + if ("SMN1 copies" in page) != opened: + out_.append(f"the card's fact on SMN1 {'not ' if opened else ''}shown") return out_ try: with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: @@ -1112,21 +1250,29 @@ def problems(text, opened): res = live_results.build(OTHERS[1], summary) with open(rj, "w") as f: json.dump(res, f) - bad = problems(drawn(), False) + bad = problems(drawn(), False) + phone_problems() if not bad: for _ in range(2): # the CPSR card holds both results: one click opens one b.eval("document.querySelector('#card-cpsr .veil button').click(); true") time.sleep(0.5) bad = problems(shown(), True) - check("browser: on the son's page a CHIP call and a copy-number dot in the gene of a closed result stay out of " - "sight until it is opened", not bad, bad) - a = "add(text, widget(of[1]));" - if a not in keep_js: - check("control: person.js that draws every item (the planted line is in person.js)", False, a) - else: + check("browser: on the son's page a CHIP call, a copy-number dot and a card's fact in the gene of a closed " + "result stay out of sight (the fact in the card's line on a phone too) until it is opened", not bad, bad) + for desc, a, z in ( + ("person.js that draws every item", "add(text, widget(of[1]));", "add(text, widget(c.widget));"), + ("person.js that shows every fact", "facts = (c.facts || []).filter(function (f, i) { return !(ft[i] && tagClosed(ft[i])); });", + "facts = c.facts || [];"), + ("person.js whose card line reads every fact", + "(openFacts(c)[0][0] ? openFacts(c)[0][0][0] + ': ' + openFacts(c)[0][0][1] : NOT_ALL_CLEAR)", + "(c.facts && c.facts[0] ? c.facts[0][0] + ': ' + c.facts[0][1] : NOT_ALL_CLEAR)")): + if a not in keep_js: + check(f"control: {desc} (the planted line is in person.js)", False, a) + continue + with open(js, "w") as f: + f.write(keep_js.replace(a, z)) + control(desc, not (problems(drawn(), False) + phone_problems())) with open(js, "w") as f: - f.write(keep_js.replace(a, "add(text, widget(c.widget));")) - control("person.js that draws every item", not problems(drawn(), False)) + f.write(keep_js) finally: with open(rj, "w") as f: f.write(keep) diff --git a/tests/test_live_results.py b/tests/test_live_results.py index becc5e182..25fd54a9d 100644 --- a/tests/test_live_results.py +++ b/tests/test_live_results.py @@ -44,15 +44,13 @@ home page's view of the run (overview) lists no such finding among its first ones and its closed lead names none; a CHIP call, a copy-number dot and a fact that name a closed result's gene carry its id (the CHIP chart's - panel of genes never does), results.json carries the closed results as - veil.spans, and core.js veilId(), which tags the family page's rows of a - child, answers each row as veil_id() does, under node. + panel of genes never does), and results.json carries the closed results as + veil.spans (the family page closes a child's places while one is closed). Each check runs again on a planted mistake and must fail there: 1 with VNTYPER's entry dropped, 2 with one card's caveat blanked, 3 with "ok" planted in a card and "All clear" in a script, 4 with the hits emptied, 5 with the results route registered without the login, 6 with a row printed as its Python dict, 7 with the Slivar adapter returning nothing, -8 with a veil rule that tags nothing (rows, lines, facts and drawings) and one that tags every row, and -veilId() with its gene rule or its overlap rule dropped and veilIds() keeping only the first result. A check +8 with a veil rule that tags nothing (rows, lines, facts and drawings) and one that tags every row. A check that cannot fail is no check. Run: python3 tests/test_live_results.py @@ -437,48 +435,6 @@ def widget_problems(): return out -# Rows as the family page tags them (family-page.js): [text, chrom, pos, end], against two closed results. -VEIL_SPANS = [["sf-kcnh2", "KCNH2", "chr7", 150960000, 150960000], ["sv-del", "KCNH2", "chr7", 150955000, 150965000], - ["two-hits-hla", "HLA-A", None, None, None]] -VEIL_ROWS = [["chr7:150960000 C>T", "chr7", "150960000", None], ["chr7:150961000 G>A", "chr7", "150961000", None], - ["chr7:150970000 G>A", "chr7", "150970000", None], ["chr3:48211904 C>T", "chr3", "48211904", None], - ["KCNH2 chr7:94371020:G>A chr7:94398114:T>C", None, None, None], - ["G0187 chr7:150,962,000:G>A chr7:1:T>C", None, None, None], ["G0412 chr2:1:A>G", None, None, None], - ["chr7:61600000-159285973", "chr7", "61600000", "159285973"], ["in KCNH2.", None, None, None], - ["HLA-A*02:01", None, None, None], ["HLA-AB", None, None, None], ["kcnh2 lower case", None, None, None], - ["7:150960000", "7", 150960000, None], ["chrX:1-2", "chrX", "x", None], ["", "chr7", "150955000", "150965000"]] -CORE_JS = os.path.join(ASSETS, "core.js") -NODE_VEIL = ("const vm=require('vm'),fs=require('fs');const w={};" - "const doc={documentElement:{setAttribute(){}},addEventListener(){}};" - "vm.runInNewContext(fs.readFileSync(process.argv[1],'utf8'),{window:w,document:doc});" - "const sp=JSON.parse(process.argv[2]);" - "console.log(JSON.stringify(JSON.parse(process.argv[3]).map(r=>[w.PGPUI.veilId(sp,r[0],r[1],r[2],r[3])," - "w.PGPUI.veilIds(sp,r[0],r[1],r[2],r[3])])))") - - -def node_veil(js_path): - """[veilId(), veilIds()] of core.js at JS_PATH for each of VEIL_ROWS, under node, or the error.""" - node = shutil.which("node") - if not node: - return "no node" - p = subprocess.run([node, "-e", NODE_VEIL, js_path, json.dumps(VEIL_SPANS), json.dumps(VEIL_ROWS)], - capture_output=True, text=True) - return json.loads(p.stdout) if p.returncode == 0 else p.stderr - - -def veil_js_problems(js_path): - """What veilId() of core.js answers differently from veil_id() on VEIL_ROWS, and veilIds() from veil_id() taken - one closed result at a time (every result the row belongs to).""" - spans = [tuple(x) for x in VEIL_SPANS] - want = [[live_results.veil_id(spans, *r), [s_[0] for s_ in spans if live_results.veil_id([s_], *r)]] for r in VEIL_ROWS] - got = node_veil(js_path) - if got != want: - return [f"veilId, veilIds {got} != veil_id {want}"] - if len({w[0] for w in want}) < 4 or not any(len(w[1]) > 1 for w in want): - return [f"the rows exercise too few answers: {want}"] - return [] - - def overview_problems(): """What is wrong with the home page's view of a child's run (overview): a finding that names a closed result (a symptom ranking of its gene) is listed among the first findings, or the closed lead still names it.""" @@ -599,20 +555,6 @@ def tagged(sample_dir): bad = widget_problems() check("8. a CHIP call, a copy-number dot and a fact that name a closed result's gene carry its id, the CHIP " "chart's gene panel none, and results.json carries the closed results as veil.spans", not bad, bad) - check("8. node is on the PATH (ubuntu-latest has it)", shutil.which("node") is not None) - bad = veil_js_problems(CORE_JS) - check("8. core.js veilId() tags each family-page row as veil_id() does, and veilIds() with every result " - "veil_id() finds one result at a time (under node)", not bad, bad) - with open(CORE_JS) as f: - core = f.read() - for a, z, desc in (("if (s[1] && words[s[1]]) { return s[0]; }", "", "veilId() without its gene rule"), - ("l[1] <= s[4] && l[2] >= s[3]", "false", "veilId() without its overlap rule"), - (".map(function (s) { return s[0]; });\n }", ".map(function (s) { return s[0]; }).slice(0, 1);\n }", - "veilIds() keeping only the first result")): - planted = os.path.join(work, "core-planted.js") - with open(planted, "w") as f: - f.write(core.replace(a, z)) - control(desc, a in core and not veil_js_problems(planted)) bad = veil_problems(*tagged(d3)) check("8. the planted ClinVar hit's row, viewer link, tick and star bar carry its secondary finding's id, the " "deletion's span its own, every other card's row of the gene and the symptom ranking's finding one of them, " diff --git a/tests/test_owngenome_live_family.py b/tests/test_owngenome_live_family.py index 5965c1045..27c67ed60 100755 --- a/tests/test_owngenome_live_family.py +++ b/tests/test_owngenome_live_family.py @@ -344,6 +344,10 @@ def browser_checks(port, work): loaded = b.wait("var i = document.querySelector('img.pedigree'); i && i.complete && i.naturalWidth > 0 && " "i.naturalWidth", 10) check("browser: the pedigree image loads", loaded, loaded) + # father_a and mother_a are also children (of gf_a and gm_a) whose results this server cannot read, so the + # couple screen stays closed whole until the button of each opens it. + shut = open_screen(b) + check("browser: the couple screen of a partner who is a child stays closed until 'Show the screen'", shut, shut) couple = b.eval("var p = document.querySelector('p[data-couple=\\'father_a mother_a\\']'); p && p.textContent") check("browser: the couple line counts 1 shared and 1 X-linked and links the report", couple and "1 gene(s) where both carry a finding, 1 X-linked" in couple @@ -368,6 +372,16 @@ def browser_checks(port, work): b.close() +def open_screen(b): + """Click the closed boxes of father_a's and mother_a's couple screen, one per partner, once they show with no + table or link: whether they did and the screen then opened.""" + shut = b.wait("document.querySelectorAll('#ch-couples .veil').length === 2 && !document.querySelector('#ch-couples table') " + "&& !document.querySelector('#ch-couples p[data-couple] a') && 'shut'", 10) + for who in ("father_a", "mother_a"): + b.eval("var v = document.querySelector('#ch-couples .veil[data-veil-child=%s] button'); v && v.click(); true" % who) + return bool(shut) and bool(b.wait("!!document.querySelector('#ch-couples table') && !document.querySelector('#ch-couples .veil')", 5)) + + # A stale blood-group section with two systems past ABO and RhD, for child_a's summary.json. STALE_SUMMARY = {"sections": {"bloodtype": {"state": "stale", "data": { "abo": "A1", "rhd": "D+", "systems": [{"system": "ABO", "phenotype": "A1"}, {"system": "RhD", "phenotype": "D+"}, @@ -396,6 +410,7 @@ def depth_checks(b, base, root): clin = os.path.join(root, "child_a", "clinical") try: b.go(base + "/runs/family_a/") + open_screen(b) ready = b.wait("document.documentElement.dataset.pgpStatus === 'ready' && " "!!document.querySelector('div[data-shared=GENE4]')") four = b.eval("var k = document.querySelector('div[data-shared=GENE1] .kids4 .grid4'); k ? k.children.length : -1") From c31290e0952b5662f91165d8dce10b52b65a4aa6 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:40:27 +0100 Subject: [PATCH 13/27] fix(live): keep a phone's person page short with the genome map On Linux fonts DEMO-002's page at 390 px came to 8062 px, over the 8000 px bound. A phone now gets 10 px rows, one paragraph for the genome sentences and a chip without its reason. The map is also no longer sized while it has no width, which gave a phone the desktop's rows. Bead: pgp-iqon.8 --- assets/live/map.css | 11 +++++++++++ assets/live/map.js | 29 +++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/assets/live/map.css b/assets/live/map.css index cc3e42e00..8dc7df978 100644 --- a/assets/live/map.css +++ b/assets/live/map.css @@ -55,3 +55,14 @@ .gm-margin { margin-top: 12px; } .gm-narration { font-size: 14px; } } +@media (max-width: 520px) { + .gmap { margin-top: 22px; } + .gm-why { display: none; } + .gm-margin { margin-top: 4px; } + .gm-head { gap: 4px; margin-bottom: 6px; } + .gm-chip { padding: 4px 8px; } + .gm-chip button { padding: 3px 9px; } + .gm-narration { margin-top: 4px; } + .gm-titlerow { row-gap: 6px; } + .gm-title { font-size: 20px; } +} diff --git a/assets/live/map.js b/assets/live/map.js index 94ffbaee0..c8b2e3c35 100644 --- a/assets/live/map.js +++ b/assets/live/map.js @@ -30,8 +30,8 @@ var MONO = 'ui-monospace, "SF Mono", Menlo, monospace'; // The chromosome level ends at 16,667 bp per CSS pixel: closer views (region, gene, reads) come later. var CHROM_BPP = 16667; - // A genome row's least height: 22 CSS px, 14 on a phone (the page there is long enough already). - var ROW_MIN = 22, ROW_PHONE = 14; + // A genome row's least height: 22 CSS px, 10 on a phone (the page there is long enough already). + var ROW_MIN = 22, ROW_PHONE = 10; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; function lerp(a, b, t) { return a + (b - a) * t; } @@ -180,8 +180,11 @@ } // --- the canvas size, the hatch pattern --- + // False while the stage has no width (not in the page yet, or hidden): sized then, a phone would get the + // desktop's rows. The ResizeObserver draws once it has one. function size() { - var w = Math.max(280, Math.floor(stage.clientWidth || 680)); + if (!stage.clientWidth) { return false; } + var w = Math.max(280, Math.floor(stage.clientWidth)); phone = w < 520; var n = M ? M.chroms.length : 24; var h = 18 + n * (phone ? ROW_PHONE : ROW_MIN); @@ -194,6 +197,7 @@ canvas.style.setProperty('height', h + 'px'); cacheKey = ''; } + return true; } function pattern(alpha) { var p = document.createElement('canvas'); @@ -295,7 +299,7 @@ M.chroms.forEach(function (ch, i) { var yc = rowY(i); c.fillStyle = INK3; - c.font = '11px ' + SANS; + c.font = (phone ? '9px ' : '11px ') + SANS; c.textAlign = 'right'; c.textBaseline = 'middle'; c.fillText(short(ch.n), gutG() - 10, yc); @@ -354,7 +358,7 @@ raf = 0; if (!M) { return; } var t = performance.now(); - size(); // the page may have changed width since the last frame (a phone turned, a fold opened) + if (!size()) { return; } // the page may have changed width since the last frame (a phone turned, a fold opened) step(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); @@ -758,7 +762,14 @@ add(narr, el('p', {}, fs.some(function (r) { return r.chrom === ch.n; }) ? 'No finding in this stretch.' : noFindingHere())); } if (lv === 'genome') { - add(narr, runs(M.text.genome)); + var tail = runs(M.text.genome), lead = narr.children.length === 1 ? narr.firstChild : null; + if (phone && lead) { + // On a phone the count and the runs share one paragraph. + add(lead, ' '); + while (tail.firstChild) { lead.appendChild(tail.firstChild); } + } else { + add(narr, tail); + } if (!phone) { add(narr, el('p', { 'class': 'dim' }, 'Click a chromosome to open it.')); } } else { var r = M.roh[ch.n], n = 0, longest = 0; @@ -828,16 +839,18 @@ var s = stateOf(id); if (s === 'closed') { (M.gated[id] ? gates : acts).push(id); } else if (s === 'opened') { opened.push(id); } }); + // Why it is closed: said beside the count, left out on a phone (the card says it too). + function why() { return el('span', { 'class': 'gm-why' }, ' · Not drawn until opened on purpose'); } if (acts.length) { var c = el('div', { 'class': 'gm-chip closed', 'data-closed': 'act' }); - add(c, el('span', {}, (acts.length === 1 ? '1 result closed' : acts.length + ' results closed') + ' · Not drawn until opened on purpose')); + add(c, add(el('span', {}, acts.length === 1 ? '1 result closed' : acts.length + ' results closed'), why())); add(c, confirming === 'act' ? confirmBox(acts[0], false) : button('Open', function () { confirming = 'act'; drawChips(); })); add(chips, c); } gates.forEach(function (id) { var g = M.gated[id], name = g.gene || 'This result'; var c = el('div', { 'class': 'gm-chip closed', 'data-closed': 'gate' }); - add(c, el('span', {}, name + ' closed · Not drawn until opened on purpose')); + add(c, add(el('span', {}, name + ' closed'), why())); add(c, confirming === id ? confirmBox(id, true) : button('Open ' + name, function () { confirming = id; drawChips(); })); add(chips, c); }); From 1ff5652230905916288f108c91d06cdbfea43cb4 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:47:45 +0100 Subject: [PATCH 14/27] test(live): check a closed family page only once the child's results have settled A page still waiting for a child's results keeps every place closed anyway, so the checks of the unreadable state could pass on a request that never ended. They now first require the page to report the child's results as known or failed. --- tests/test_live_demo.py | 6 +++++- tests/test_owngenome_live_family.py | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index ca4f4fb45..6dd6b8df9 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -100,6 +100,7 @@ import copy import json import os +import re import shutil import sys import tempfile @@ -905,8 +906,11 @@ def family_unknown(b, base, opens=True): planted results shown; then (OPENS) the de novo box's own button opens his de novo rows and no other place.""" gene, hit, sv = planted_words() family_page(b, base) + # A page still waiting for his results keeps everything closed anyway: the check is on the state it settles in. + kids = b.eval("document.documentElement.dataset.pgpKids || ''") or "" shown = b.eval(FAMILY_SHOWN_JS) or "" - out = [f"{w} shown with the son's results unreadable" for w in [gene, GRANDPARENT, PAIR_B] + hit + sv[:1] if w in shown] + out = [] if re.search(r"\bDEMO-003:(known|failed)\b", kids) else [f"the son's results never settled: {kids!r}"] + out += [f"{w} shown with the son's results unreadable" for w in [gene, GRANDPARENT, PAIR_B] + hit + sv[:1] if w in shown] if boxes_of(b) != SON_BOXES or son_tables(b) is None or len(son_tables(b)) != 1: out.append(f"closed places {boxes_of(b)}, tables {son_tables(b)}") words = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('.veil[data-veil-child=\"DEMO-003\"] button'), " diff --git a/tests/test_owngenome_live_family.py b/tests/test_owngenome_live_family.py index 27c67ed60..42d4253fa 100755 --- a/tests/test_owngenome_live_family.py +++ b/tests/test_owngenome_live_family.py @@ -355,9 +355,11 @@ def browser_checks(port, work): b.eval("document.querySelector('details[data-child=child_a]').open = true; true") # child_a's results cannot be built here (its summary is cut short), so the page cannot tell whether a # result to act on is closed: the map stays closed until "Show the map" is clicked. - shut = b.wait("var v = Array.prototype.filter.call(document.querySelectorAll('details[data-child=child_a] .veil'), " + # Only once the page has given up on child_a's results: a page still waiting keeps the map closed anyway. + shut = b.wait("/\\bchild_a:failed\\b/.test(document.documentElement.dataset.pgpKids || '') && " + "(function () { var v = Array.prototype.filter.call(document.querySelectorAll('details[data-child=child_a] .veil'), " "function (x) { return x.textContent.indexOf('Show the map') >= 0; })[0]; " - "v && !document.querySelector('details[data-child=child_a] img') && (v.querySelector('button').click(), true)", 10) + "return v && !document.querySelector('details[data-child=child_a] img') && (v.querySelector('button').click(), true); }())", 10) check("browser: child_a's chromosome map stays closed while its results cannot be read", shut, shut) seg = b.wait("var i = document.querySelector('details[data-child=child_a] img'); " "i && i.complete && i.naturalWidth > 0 && i.getAttribute('src')", 10) @@ -375,7 +377,8 @@ def browser_checks(port, work): def open_screen(b): """Click the closed boxes of father_a's and mother_a's couple screen, one per partner, once they show with no table or link: whether they did and the screen then opened.""" - shut = b.wait("document.querySelectorAll('#ch-couples .veil').length === 2 && !document.querySelector('#ch-couples table') " + shut = b.wait("!/:pending\\b/.test(document.documentElement.dataset.pgpKids || ':pending') && " + "document.querySelectorAll('#ch-couples .veil').length === 2 && !document.querySelector('#ch-couples table') " "&& !document.querySelector('#ch-couples p[data-couple] a') && 'shut'", 10) for who in ("father_a", "mother_a"): b.eval("var v = document.querySelector('#ch-couples .veil[data-veil-child=%s] button'); v && v.click(); true" % who) From 4409a9680e7345eeb3f24131b9da00ada3f6b039 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:44:08 +0100 Subject: [PATCH 15/27] fix(live): keep an overlapping closed result off the map and answer the map review A row that carries another result's tag now also asks its own result, so opening one closed result never draws a second one under its tag. The runs of homozygosity wait for their step in a replay, Back to an address with no place returns to the whole genome, a DPR change resizes the bitmap, a failed reload clears the chips, and an open chromosome gets zoom buttons for touch. The tests use OG when set and take no map screenshot of a map that did not draw. --- assets/live/map.css | 3 ++ assets/live/map.js | 72 +++++++++++++++++++++++++++----------- assets/live/person.js | 7 ++-- docs/live-site.md | 2 +- tests/test_live_demo.py | 76 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 133 insertions(+), 27 deletions(-) diff --git a/assets/live/map.css b/assets/live/map.css index 8dc7df978..99d59a657 100644 --- a/assets/live/map.css +++ b/assets/live/map.css @@ -66,3 +66,6 @@ .gm-titlerow { row-gap: 6px; } .gm-title { font-size: 20px; } } +.gm-zoom { display: inline-flex; border: 1px solid var(--hair-2); border-radius: 999px; overflow: hidden; } +.gm-zoom[hidden] { display: none; } +.gm-zoom button { border: 0; border-radius: 0; background: transparent; color: var(--ink-2); padding: 6px 14px; font-size: 14px; line-height: 1; } diff --git a/assets/live/map.js b/assets/live/map.js index c8b2e3c35..e7032c7c9 100644 --- a/assets/live/map.js +++ b/assets/live/map.js @@ -3,8 +3,9 @@ // the 24 chromosomes as rows with their bands, the masked spans hatched, each finding with a place as its // level's glyph, and the runs of homozygosity; a click opens a chromosome in place, where the bands, the // findings and the runs get lanes of their own under a ruler. Every mark comes from the document, never from -// results.json, and every sentence the margin shows is one the document wrote (text, phrase, empty, note), -// with the counts filled in here. +// results.json. The margin's findings count, chromosome sentences, runs sentence and finding phrases are the +// document's (text, phrase, empty, note); this file adds only short lines of its own: an empty lane or +// stretch, the runs in view, how to move, and why a layer is not on the map. // // The veil. The document is the same for every reader: each row a closed result could reveal carries the id // of that result (veil). Whether it is closed is the page's question (person.js veil()): a closed row is not @@ -93,14 +94,18 @@ }); var gated = {}; rows.forEach(function (r) { if (r.gate) { gated[r.id] = r; } }); + // What can be closed: each veil tag, and each result to act on that carries another result's tag (a row + // over two of them carries only the first one's tag, and one over APOE carries APOE's): its own id too. + var closable = veilIds.slice(); + rows.forEach(function (r) { if (r.veil && r.level === 'act' && !r.gate && closable.indexOf(r.id) < 0) { closable.push(r.id); } }); return { doc: d, id: id, label: person.label || id, chroms: chroms, by: by, bands: bands, roh: roh, bins: bins, - binSize: (per.roh_bins && per.roh_bins.size) || 1e6, findings: rows, gated: gated, veilIds: veilIds, + binSize: (per.roh_bins && per.roh_bins.size) || 1e6, findings: rows, gated: gated, veilIds: veilIds, closable: closable, layers: d.layers || {}, text: d.text || {}, art: (d.dict && d.dict.art) || [] }; } // --- the figure --------------------------------------------------------------------------------------- // create({url, veil(id, gated) -> 'open'|'closed'|'opened', now(id) -> shown at this moment, open(id), - // close(id), fallback() -> rows}): one figure per page. The page calls refresh(key) on each of its redraws; a new key + // close(id), stepDone(step) -> ended at this moment, fallback() -> rows}): one figure per page. The page calls refresh(key) on each of its redraws; a new key // fetches the document again and keeps the camera. function create(opts) { var M = null, key = null, failure = null, t0 = 0; @@ -113,9 +118,14 @@ var stopG = el('button', { type: 'button', 'data-stop': 'genome' }, 'Genome'); var stopC = el('button', { type: 'button', 'data-stop': 'chromosome' }, 'Chromosome'); add(gauge, stopG, stopC); + // Zoom buttons for an open chromosome: the touch path to what the wheel and the + and - keys do. + var zoom = el('div', { 'class': 'gm-zoom', role: 'group', 'aria-label': 'Zoom', hidden: 'hidden' }); + var zoomOut = el('button', { type: 'button', 'aria-label': 'Zoom out', 'data-zoom': 'out' }, '−'); + var zoomIn = el('button', { type: 'button', 'aria-label': 'Zoom in', 'data-zoom': 'in' }, '+'); + add(zoom, zoomOut, zoomIn); var crumb = el('nav', { 'class': 'gm-crumb mono', 'aria-label': 'Where the map is' }); var chips = el('div', { 'class': 'gm-chips' }); - add(head, kicker, add(el('div', { 'class': 'gm-titlerow' }), title, gauge), crumb, chips); + add(head, kicker, add(el('div', { 'class': 'gm-titlerow' }), title, gauge, zoom), crumb, chips); var stage = el('div', { 'class': 'gm-stage' }); var canvas = el('canvas', { 'class': 'gm-canvas', tabindex: '0', role: 'img', 'aria-describedby': 'gm-narration' }); var tip = el('div', { 'class': 'gm-tip', role: 'tooltip', hidden: 'hidden' }); @@ -149,7 +159,19 @@ function stateOf(id) { return opts.veil(id, !!(M && M.gated[id])); } // A row shows when its result is not closed and the page shows its finding at this moment (a replay // before the finding's step ended does not). - function shownRow(r) { return (!r.veil || stateOf(r.veil) !== 'closed') && (!opts.now || opts.now(r.id)); } + // A row under another result's tag also stays hidden while its own result is closed. + function shownRow(r) { + return (!r.veil || stateOf(r.veil) !== 'closed') && (r.veil === r.id || M.closable.indexOf(r.id) < 0 || + stateOf(r.id) !== 'closed') && (!opts.now || opts.now(r.id)); + } + // The runs of homozygosity, as this page shows them now: a replay before their step ended has none yet. + function rohLayer() { + var lay = M.layers.roh; + if (lay && landed(lay) && lay.step && opts.stepDone && !opts.stepDone(lay.step)) { + return { state: 'waiting', step: lay.step, step_name: lay.step_name }; + } + return lay; + } function drawn() { return M ? M.findings.filter(shownRow) : []; } // --- geometry --- @@ -189,7 +211,7 @@ var n = M ? M.chroms.length : 24; var h = 18 + n * (phone ? ROW_PHONE : ROW_MIN); DPR = Math.min(2, window.devicePixelRatio || 1); - if (w !== CW || h !== CH) { + if (w !== CW || h !== CH || canvas.width !== Math.round(w * DPR)) { CW = w; CH = h; canvas.width = Math.round(w * DPR); canvas.height = Math.round(h * DPR); @@ -306,7 +328,7 @@ c.textAlign = 'left'; body(c, ch, gx, yc, 8, 1); // Runs of homozygosity under the body: the long ones solid, the rest as 1 Mb coverage bins. - var bins = M.bins[ch.n], ry = yc + 6.5; + var rohOn = landed(rohLayer()), bins = rohOn && M.bins[ch.n], ry = yc + 6.5; if (bins) { c.fillStyle = INK2; for (var b = 0; b < bins.length; b++) { @@ -316,7 +338,7 @@ } c.globalAlpha = 1; } - var rr = M.roh[ch.n]; + var rr = rohOn && M.roh[ch.n]; if (rr) { c.strokeStyle = INK; c.lineWidth = 2.5; @@ -545,7 +567,7 @@ }); } function openRoh(L, v, ch) { - var lay = M.layers.roh || {}, g = gut(), y = L.ry + L.rh / 2; + var lay = rohLayer() || {}, g = gut(), y = L.ry + L.rh / 2; if (!landed(lay)) { laneWords(ctx, layerWords(lay), g + 4, y); return; } var r = M.roh[ch.n]; if (!r || !r.n) { laneWords(ctx, 'No run of homozygosity on this chromosome.', g + 4, y); return; } @@ -570,7 +592,7 @@ function noFindingHere() { return closedAny() ? 'No open finding with a place on this chromosome.' : 'No finding with a place on this chromosome.'; } - function closedAny() { return M.veilIds.some(function (id) { return stateOf(id) === 'closed'; }); } + function closedAny() { return M.closable.some(function (id) { return stateOf(id) === 'closed'; }); } function layerWords(lay) { if (lay.state === 'missing') { return 'Not on the map: ' + (lay.note || ('step ' + lay.step + ' wrote no ' + (lay.source || 'file') + '.')); } if (lay.state === 'unreadable') { return 'Not on the map: ' + (lay.source || '') + ' could not be read' + (lay.note ? ' (' + lay.note + ')' : '') + '.'; } @@ -682,7 +704,8 @@ if (!M) { return; } var h; try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return; } - if (!h) { return; } + // Back to the address with no place (the page as first loaded): the whole genome again. + if (!h) { if (!scroll && tgt.k) { tgt.k = 0; last = 0; schedule(); } return; } var p = parsePlace(h); if (p === null) { return; } if (p === false) { say('Could not read the place “' + h + '”'); return; } @@ -717,6 +740,7 @@ fig.setAttribute('data-view', locusWords()); stopG.setAttribute('aria-pressed', String(lv === 'genome')); stopC.setAttribute('aria-pressed', String(lv !== 'genome')); + zoom.hidden = lv === 'genome'; crumb.textContent = ''; crumb.hidden = lv === 'genome'; // the gauge already says Genome var gl = el('button', { type: 'button', 'class': 'gm-crumb-g' }, 'Genome'); @@ -762,7 +786,8 @@ add(narr, el('p', {}, fs.some(function (r) { return r.chrom === ch.n; }) ? 'No finding in this stretch.' : noFindingHere())); } if (lv === 'genome') { - var tail = runs(M.text.genome), lead = narr.children.length === 1 ? narr.firstChild : null; + var rl = rohLayer(); + var tail = rl === M.layers.roh ? runs(M.text.genome) : runs([{ t: layerWords(rl), b: false }]), lead = narr.children.length === 1 ? narr.firstChild : null; if (phone && lead) { // On a phone the count and the runs share one paragraph. add(lead, ' '); @@ -774,10 +799,10 @@ } else { var r = M.roh[ch.n], n = 0, longest = 0; if (r) { for (var i = first(r, v[0]); i < r.n && r.s[i] < v[1]; i++) { n++; longest = Math.max(longest, r.e[i] - r.s[i]); } } - if (n && landed(M.layers.roh || {})) { + if (n && landed(rohLayer())) { add(narr, el('p', {}, 'Runs of homozygosity in view: ' + n + ', the longest ' + mb(longest) + ' Mb.')); } - if (!phone) { add(narr, el('p', { 'class': 'dim' }, 'Drag to move along it, scroll to zoom, Esc for the whole genome.')); } + if (!phone) { add(narr, el('p', { 'class': 'dim' }, 'Drag to move along it, scroll or use + and − to zoom, Esc for the whole genome.')); } } drawLayers(); drawMarks(); @@ -793,7 +818,7 @@ function drawLayers() { layersBox.textContent = ''; [['findings', 'Findings'], ['roh', 'Runs of homozygosity']].forEach(function (x) { - var lay = M.layers[x[0]]; + var lay = x[0] === 'roh' ? rohLayer() : M.layers[x[0]]; var row = el('div', { 'class': 'gm-layer', 'data-layer': x[0], 'data-state': lay ? lay.state : 'missing' }); var dd = el('dd', {}); if (!lay) { add(dd, 'Not on the map: this server’s map has no ' + x[1].toLowerCase() + ' layer.'); } @@ -835,7 +860,7 @@ chips.textContent = ''; if (!M) { return; } var acts = [], gates = [], opened = []; - M.veilIds.forEach(function (id) { + M.closable.forEach(function (id) { var s = stateOf(id); if (s === 'closed') { (M.gated[id] ? gates : acts).push(id); } else if (s === 'opened') { opened.push(id); } }); @@ -971,9 +996,7 @@ var i = (open ? tgt.ci : (e.key === ']' ? -1 : M.chroms.length)) + (e.key === ']' ? 1 : -1); openChrom((i + M.chroms.length) % M.chroms.length, undefined, undefined, true); } else if (open && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '−')) { - tgt.ls = Math.log(span * (e.key === '-' || e.key === '−' ? 1.6 : 1 / 1.6)); - clampTgt(); - changed(false); + zoomBy(e.key === '-' || e.key === '−' ? 1.6 : 1 / 1.6); } else if (open && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { tgt.c += (e.key === 'ArrowLeft' ? -0.2 : 0.2) * span; clampTgt(); @@ -982,6 +1005,14 @@ else { done = false; } if (done) { e.preventDefault(); } }); + function zoomBy(f) { + if (!M || level() === 'genome') { return; } + tgt.ls = Math.log(Math.exp(tgt.ls) * f); + clampTgt(); + changed(false); + } + zoomOut.addEventListener('click', function () { zoomBy(1.6); }); + zoomIn.addEventListener('click', function () { zoomBy(1 / 1.6); }); stopG.addEventListener('click', function () { closeChrom(true); }); stopC.addEventListener('click', function () { if (level() !== 'genome') { openChrom(tgt.ci, undefined, undefined, true); return; } @@ -1010,6 +1041,7 @@ } narr.textContent = ''; layersBox.textContent = ''; + chips.textContent = ''; // a chip drawn before the failure may name a result closed since } function load(k) { key = k; diff --git a/assets/live/person.js b/assets/live/person.js index 83ad5a151..021cff5e1 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -553,7 +553,10 @@ // Whether the page shows a finding of the map at this moment: its card's steps are done (a replay draws // the run's end results from its first frame). A row with no finding of these results (the map's own APOE // row) follows its card's fold instead. - function mapNow(id) { var f = findingById(id); var c = f && cardById(f.card); return !f || !!(c && gate(c) === 'show'); } + // A finding this page keeps closed is not shown either, whatever row tag it shares with another result. + function mapNow(id) { var f = findingById(id); var c = f && cardById(f.card); return !f || (!!(c && gate(c) === 'show') && !veiled(f)); } + // Whether a step of the run has ended at this moment (a replay before it has not): the map's layers follow it. + function stepDone(step) { var s = stepsBy()[step]; return !s || s.state === 'done'; } // Without the map (a browser with no DecompressionStream, or a map the server could not build): the findings // with a place, as a table, each through this page's veil first. function mapFallback() { @@ -881,7 +884,7 @@ // reader left it. It asks this page's veil for each closed row, and opens or closes a result through it. if (window.PGPMap) { if (!genomeMap) { - genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, open: function (id) { setShown(id, true); }, + genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, stepDone: stepDone, open: function (id) { setShown(id, true); }, close: function (id) { setShown(id, false); }, fallback: mapFallback }); } add(slot, genomeMap.node); diff --git a/docs/live-site.md b/docs/live-site.md index 025b3f34c..d5c57e616 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -70,7 +70,7 @@ While the run goes, the page still opens on the report being written. A short ba The header search finds a gene or rsID across the results and opens the card that names it; on a page that keeps results closed it leaves out every hit that names one (above). One row of the results that the page cannot read (a field of the wrong type) costs that row only: it shows as a finding on its card, "A row of this section could not be read", at the highest level its rule can give (to act on for a rule that can give one, so a page that keeps results closed keeps that card closed too), the card whose table cannot be drawn says so, and every other card and finding is on the page. `#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. -**Figure 1, the genome map.** The 24 chromosomes as rows, to scale (23 for a person with no Y), each with its bands, the centromere as a waist and the stretches few reads can map hatched (centromeres, heterochromatin, the acrocentric arms). Each finding with a place sits on its chromosome as its level's shape, with its gene beside it when the name fits; runs of homozygosity of 5 Mb or more are a line under the row, shorter ones a faint band. A finding that names a gene but no position (a medicine gene, Lp(a)) sits on that gene's span; a finding with neither stays in its section only. Click a chromosome (or, with the map focused, press `[` or `]`) and it opens in place: an overview with the part in view, a ruler in Mb, the bands with their names, a findings lane with each gene and its level's words, and a lane for the runs of homozygosity, each run with its length. Drag to move along it, scroll to zoom in (as far as about 10 Mb across a wide screen; closer views come later), and press Esc (or click Genome) to go back. A dashed line marks the centre, and the margin says what is under it: the chromosome, the position and the band, the findings in view and the runs. Every sentence in the margin is one the map document carries; the page only fills in the counts. The address follows the map: `#genome`, `#chr7`, or `#chr7:117.4-137.4M`, and a link to one of them opens that view; a place the page cannot read shows a short note and leaves the map as it was. The map comes from `/runs//map.json`, which the map plugin (`bin/owngenome_live_map.py`) builds with `og map build` from the run folder and the results the page gets (`rust/crates/og-map/README.md` describes the document). It needs og on the host (`scripts/setup.sh --og`); without it, or in a browser that cannot unpack the file (no `DecompressionStream`), the figure says so and lists the findings with a place in a table instead. A layer whose file is missing or cannot be read says so in words, never as a blank lane. +**Figure 1, the genome map.** The 24 chromosomes as rows, to scale (23 for a person with no Y), each with its bands, the centromere as a waist and the stretches few reads can map hatched (centromeres, heterochromatin, the acrocentric arms). Each finding with a place sits on its chromosome as its level's shape, with its gene beside it when the name fits; runs of homozygosity of 5 Mb or more are a line under the row, shorter ones a faint band. A finding that names a gene but no position (a medicine gene, Lp(a)) sits on that gene's span; a finding with neither stays in its section only. Click a chromosome (or, with the map focused, press `[` or `]`) and it opens in place: an overview with the part in view, a ruler in Mb, the bands with their names, a findings lane with each gene and its level's words, and a lane for the runs of homozygosity, each run with its length. Drag to move along it, scroll or press the + and − buttons beside the zoom stops to zoom in (as far as about 10 Mb across a wide screen; closer views come later), and press Esc (or click Genome) to go back. Back in the browser returns to the view before, the whole genome when the address had no place. A dashed line marks the centre, and the margin says what is under it: the chromosome, the position and the band, the findings in view and the runs. The findings count, the chromosome sentences, the runs sentence and each finding's phrase come from the map document; the page adds its own short lines: a lane or stretch with no finding, the runs in view, how to move, and why a layer is not on the map. A replay draws the runs of homozygosity only once their step has ended. The address follows the map: `#genome`, `#chr7`, or `#chr7:117.4-137.4M`, and a link to one of them opens that view; a place the page cannot read shows a short note and leaves the map as it was. The map comes from `/runs//map.json`, which the map plugin (`bin/owngenome_live_map.py`) builds with `og map build` from the run folder and the results the page gets (`rust/crates/og-map/README.md` describes the document). It needs og on the host (`scripts/setup.sh --og`); without it, or in a browser that cannot unpack the file (no `DecompressionStream`), the figure says so and lists the findings with a place in a table instead. A layer whose file is missing or cannot be read says so in words, never as a blank lane. **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. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 0fedd27d5..65a965532 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -1185,9 +1185,16 @@ def fold_checks(b, base, out): def og_binary(): - """An og that runs here: bin/og when scripts/setup.sh --og built it, else one cargo builds now from rust/ - without the BAM feature (the map reads no BAM), kept in CARGO_TARGET_DIR or a folder of the temp dir. - None when neither works.""" + """An og that runs here: OG when set (Guard's unit-tests job and the docs builds set it to the og they + built, as tests/og_binary.py reads it), else bin/og when scripts/setup.sh --og built it, else one cargo + builds now from rust/ without the BAM feature (the map reads no BAM), kept in CARGO_TARGET_DIR or a folder + of the temp dir. None when none of them works.""" + named = os.environ.get("OG") + if named: + if os.access(named, os.X_OK): + return named + print(f"OG={named} is not an executable og binary", file=sys.stderr) + return None og = os.path.join(REPO, "bin", "og") if subprocess.run([og, "map", "--help"], capture_output=True, check=False).returncode == 0: return og @@ -1426,6 +1433,48 @@ def leaks(c): return bad +def overlap_problems(b, base, out): + """On the son's closed page, a second result to act on over the first carries only the first one's tag + (rust/crates/og-map/README.md). Opening the first leaves the second closed: in no mark, sentence, link, + title or address of the map.""" + son = OTHERS[1] + path = os.path.join(out, "runs", son, "map.json") + with open(path, "rb") as fh: + keep = fh.read() + try: + doc = read_map(path) + f = doc["per"][son]["findings"] + acts = [i for i, lv in enumerate(f["level"]) if lv == "act" and not f["gate"][i]] + if not acts: + return ["the son's map has no result to act on to close"] + i, n = acts[0], len(f["id"]) + fid = f["id"][i] + for k in f: + if isinstance(f[k], list) and len(f[k]) == n: + f[k].append(f[k][i]) + plant = {"id": "planted-overlap", "gene": "PLANTEDGENE", "title": "Planted overlapping result", + "href": "finding/planted-overlap/", "phrase": "Planted overlap phrase."} + for k, v in plant.items(): + f[k][-1] = v + write_map(path, doc) + c = map_page(b, base, son, "#" + f["chrom"][i]) + if c["state"] != "drawn": + return [f"the planted map: {c['state']}"] + b.eval("document.querySelector('#f-%s .veil button').click(); true" % fid) + c = map_now(b) + bad = [] + if fid not in [m["id"] for m in c["marks"]]: + bad.append(f"the first result did not open: marks {[m['id'] for m in c['marks']]}") + leaked = [w for w in plant.values() if w in c["text"] or w in c["tip"] or w in c["hash"]] + leaked += [m["id"] for m in c["marks"] if m["id"] == "planted-overlap"] + if leaked: + bad.append(f"opening the first shows the second: {leaked}") + return bad + finally: + with open(path, "wb") as fh: + fh.write(keep) + + def missing_layer_problems(b, base, out): """A map whose ROH layer is missing says so in words, in the margin, never a blank.""" path = os.path.join(out, "runs", S, "map.json") @@ -1478,7 +1527,12 @@ def map_screens(b, base, dest): b.call("Emulation.setDeviceMetricsOverride", {"width": w, "height": h, "deviceScaleFactor": 1, "mobile": False}, b.session) for run, link, name in ((S, "", "genome"), (S, "#chr16", "chromosome"), (OTHERS[1], "", "closed")): - map_page(b, base, run, link) + got = map_page(b, base, run, link) + want = "chromosome" if link else "genome" + if got["state"] != "drawn" or got["level"] != want: + print(f"ERROR: {run}{link}: the map is {got['state']} at {got['level'] or 'no'} level, " + f"want drawn at {want}: no screenshot") + continue b.eval("document.getElementById('genome-map').scrollIntoView({block: 'start'}); true") time.sleep(0.5) png = b.call("Page.captureScreenshot", {"format": "png"}, b.session)["data"] @@ -1545,6 +1599,20 @@ def map_checks(b, base, out): finally: with open(js, "w") as fh: fh.write(keep_js) + bad = overlap_problems(b, base, out) + check("map: a second closed result to act on over the first stays closed when the first is opened", not bad, bad) + mjs = os.path.join(out, "assets", "map.js") + with open(mjs) as fh: + keep_mjs = fh.read() + own = "(r.veil === r.id || M.closable.indexOf(r.id) < 0 ||\n stateOf(r.id) !== 'closed') && " + try: + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(own, "")) + control("map.js whose rows ask only their tag, never their own result", keep_mjs.count(own) != 1 or + not overlap_problems(b, base, out)) + finally: + with open(mjs, "w") as fh: + fh.write(keep_mjs) bad = missing_layer_problems(b, base, out) check("map: a missing layer says why in words, never a blank", not bad, bad) bad = fallback_problems(b, base, out) From c3c5abc5351a008481ebdb7cfe675561a2afc02b Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:14:00 +0100 Subject: [PATCH 16/27] fix(live): read malformed veil.spans as unreadable and test each box, failed request, nothing closed and a moved run The family page took any array as veil.spans. An entry that is not [id, ...] blanked the page, and an empty list beside findings to act on opened every place. Such spans now read as unreadable, so every place stays closed. The tests now click each box in turn while the results cannot be read (the son's four boxes, the map image against the tables, the couple screen against the partner's cells), cover a results.json cut short, a child with nothing to act on and a run that moves while the page is open, and compare whole pages for a copy-number span, a CHIP call, the couple fixture and the map image, each view settled on knowing the results. Each new check has a planted control that turns it red. --- assets/live/family-page.js | 6 +- docs/live-site.md | 2 +- tests/test_live_demo.py | 275 ++++++++++++++++++++++++++++++++----- 3 files changed, 248 insertions(+), 35 deletions(-) diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 584ed17d3..575d1ee72 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -107,7 +107,11 @@ // Results the server could not build come back without their findings: as unreadable as no answer. var usable = !!(r && Array.isArray(r.findings)); var acts = usable ? r.findings.filter(function (x) { return x.level === 'act'; }).map(function (x) { return x.id; }) : []; - var next = usable ? { status: 'known', url: url, spans: Array.isArray(v.spans) ? v.spans : null, acts: acts } + // Spans that are not [id, ...] entries naming every result to act on cannot say which are closed: read + // as none given (UNKNOWN while there are results to act on). + var spansOk = Array.isArray(v.spans) && v.spans.every(function (s) { return Array.isArray(s) && typeof s[0] === 'string'; }) && + acts.every(function (id) { return v.spans.some(function (s) { return s[0] === id; }); }); + var next = usable ? { status: 'known', url: url, spans: spansOk ? v.spans : null, acts: acts } : { status: 'failed', url: url }; // An answer that cannot be read keeps what was known: the page never opens on a failed request. if (!usable && cur.status === 'known') { return; } diff --git a/docs/live-site.md b/docs/live-site.md index 89d07f916..245d2297a 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -72,7 +72,7 @@ The header search finds a gene or rsID across the results and opens the card tha **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//`). 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page, and what the page shows of a child never depends on which result is closed or where it lies. While a child has any result to act on still closed, every place of the page that could carry one closes for that child in the same way, each behind one box with the same words whatever the result: the grandparents of the painted stretches (every stretch is then drawn and listed from its parent alone), the chromosome map when it comes only as an image, every de novo candidate and every compound-het pair, the child's cell in every medicines and HLA row of the side by side, and the carrier screen of a couple the child is part of (its counts, its boxes, its table, the lead's words on it and its report link). Each "Show the result" opens one result, from whichever box it is clicked; the places come back once the child's last closed result is opened, and a reload closes them again. Opening a result opens it for that child only. The page reads each child's `results.json` to know whether a result is closed; while it cannot (no run here, no answer yet, a request that failed, results the server could not build, or results with findings to act on but no `veil.spans`), every place of that child stays closed, and each box opens only its own place on purpose: "Show the rows" its table, "Show the cells" the side by side, "Show the grandparents" the painting, "Show the map" the image and "Show the screen" the couple screen. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. Every row stays, with the parents' values: a child's medicines and HLA cells read "closed" while chapter IV's rule closes them (a row left out would leave a gap in a list everyone knows), and the haplogroups and blood groups, which name no gene, stay open. 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, or why the child was not assessed. A child's results to act on stay closed here too, as on the child's page, and what the page shows of a child never depends on which result is closed or where it lies. While a child has any result to act on still closed, every place of the page that could carry one closes for that child in the same way, each behind one box with the same words whatever the result: the grandparents of the painted stretches (every stretch is then drawn and listed from its parent alone), the chromosome map when it comes only as an image, every de novo candidate and every compound-het pair, the child's cell in every medicines and HLA row of the side by side, and the carrier screen of a couple the child is part of (its counts, its boxes, its table, the lead's words on it and its report link). Each "Show the result" opens one result, from whichever box it is clicked; the places come back once the child's last closed result is opened, and a reload closes them again. Opening a result opens it for that child only. The page reads each child's `results.json` to know whether a result is closed; while it cannot (no run here, no answer yet, a request that failed, results the server could not build, or results with findings to act on whose `veil.spans` is missing or does not list each of them), every place of that child stays closed, and each box opens only its own place on purpose: "Show the rows" its table, "Show the cells" the side by side, "Show the grandparents" the painting, "Show the map" the image and "Show the screen" the couple screen. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and every blood group system, one column each, with a value from an older result file marked in its cell. Every row stays, with the parents' values: a child's medicines and HLA cells read "closed" while chapter IV's rule closes them (a row left out would leave a gap in a list everyone knows), and the haplogroups and blood groups, which name no gene, stay open. On a phone the grid becomes a list of pairs. 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); under 700 px the report is short: the findings of sections 1 to 4 (to act on, medicines, family, worth a look) stay open, while the cards behind them, every information card and the bodies of sections 5 to 10 start closed, each behind one line that says what it holds (section 5 stays open when it holds a finding about data quality, or a card the report flags with no finding of its own). On a child's page, and while the family's roles are not known, the findings to act on stay behind "Show the result" on a phone too. A fold you open stays open when the page is drawn again, the phone's folds too when you turn the phone to a wide screen and back. The counts read one line per level, and the "How to read this" note and the links between findings are left out. A link to a card, a section or a finding, a search hit and a tab open the folds around it (a Ctrl, Cmd or Shift click leaves this page as it is); 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. `tests/demo/render_pictures.py --out DIR --only live-phone` draws the picture below from the demo. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 6dd6b8df9..4c38fcf84 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -61,15 +61,22 @@ text or attribute names its gene, positions or that grandparent; each click opens one result, every place comes back once both are opened, and a reload closes them again; the page is the same, byte for byte, whichever result is - closed (KCNH2, RYR1, HLA-B, a gene alone, a position) and wherever a - grandparent's stretch lies; while the page cannot read his results (no - results.json, an unreadable one, act findings without veil.spans) every - place stays closed and each box opens only its own place; with a second - child holding the same results, opening his leaves hers closed; a couple - screen whose partner is a child with a closed result closes whole (counts, - table, boxes, report link, the lead's words); his map as an image stays - closed until each result is opened from its box, and with his results - unreadable "Show the rows" leaves it closed and "Show the map" opens it; on + closed (KCNH2, RYR1, HLA-B, a gene alone, a position, a copy-number span, + a CHIP call) and wherever a grandparent's stretch lies, each view settled + on knowing his results; while the page cannot read his results (no + results.json, an unreadable one, one cut short, act findings without + veil.spans or with malformed ones) every place stays closed and each box + opens only its own place; with nothing of his to act on every place is + open with no box; with a second child holding the same results, opening + his leaves hers closed, and results opened close again when his run moves; + a couple screen whose partner is a child with a closed result closes whole + (counts, table, boxes, report link, the lead's words), the same page + whether that result is in a gene of the screen or not, and with the + partner's results unreadable its box and the cells' box each open only + their own place; his map as an image stays closed until each result is + opened from its box, the same page whichever result is closed, and with + his results unreadable "Show the rows" leaves it closed and "Show the map" + opens it and no table; on his page a CHIP call, a copy-number dot and a card's fact in the gene of a closed result stay out of sight (on a phone the card's line too) until it is opened; DEMO-001's page @@ -87,8 +94,12 @@ a side by side's cells, a couple screen and a map image that ignore the veil, a closed painting that keeps each stretch's grandparent, a box that opens every result with one click, de novo rows closed only when a result lies under one, a painting closed only when a grandparent's stretch lies over a closed result, a side by side that closes -only the closed gene's cells, one opening shared by every box while the results cannot be read, results without -veil.spans read as nothing closed and an opened result kept per run only; for the son's page, person.js that draws +only the closed gene's cells or stays open for a wide span, a page that never reads the son's results, one opening +shared by every box or 'Show the grandparents' opening the side by side while the results cannot be read, a failed +request read as nothing closed, malformed veil.spans read as given, results without veil.spans read as nothing +closed or as closed with nothing to act on, an opened result kept per run only or per child only, a couple screen +closed only for a result in its genes or opened by 'Show the cells', a map image closed only for a result with a +position, and de novo rows that 'Show the map' opens; for the son's page, person.js that draws every item, shows every fact or builds a card's line from every fact), the phone page (person.js that never folds, and a route that opens only the nearest fold), the folds (a flagged section 5 card folded, a redraw that forgets the folds, a Ctrl+click that opens one) and the person page's layout (a wire showing every line or every plain finish, a card showing @@ -872,38 +883,53 @@ def family_problems(b, base): return out -def family_view(b, base): - """The family page's HTML before any click.""" +def family_view(b, base, child=SON): + """The family page's HTML before any click, or None when the page did not settle on knowing CHILD's results.""" family_page(b, base) + kids = b.eval("document.documentElement.dataset.pgpKids || ''") or "" + if not re.search(r"\b" + re.escape(child) + r":known\b", kids): + return None return b.eval("document.getElementById('family').innerHTML") or "" -def invariance_problems(b, base, rj, res, fj, fams): +# Closed results of different kinds and places, as veil.spans holds them. +CLOSED_CASES = (("RYR1, a medicines gene", [["sf-ryr1", "RYR1", "chr19", 38440000, 38440000]]), + ("HLA-B", [["sf-hlab", "HLA-B", "chr6", 31353000, 31357000]]), + ("a gene alone", [["two-hits-g0999", "G0999", None, None, None]]), + ("a position under a de novo row", [["sf-pos", None, "chr3", 48211904, 48211904]]), + ("a copy-number span in SMN1", [["sv-chr5-70925000-70953000", "SMN1", "chr5", 70925000, 70953000]]), + ("a CHIP call in TP53", [["chip-tp53", "TP53", "chr17", 7675088, 7675088]])) + + +def invariance_problems(b, base, rj, res, fj, fams, cases=None, child=SON): """[problems]: the family page before any click differs between closed results of different genes and places - (FAMS: name -> family.json text; the results' spans set in RJ from RES), so it would tell which is closed.""" + (FAMS: name -> family.json text; CASES: name -> spans set in RJ, CHILD's results, from RES, its findings to act + on renamed to match, None for RES as it is), so it would tell which is closed; or it never knew CHILD's results.""" views = {} - for name, spans in (("the planted pair in KCNH2", None), ("RYR1, a medicines gene", [["sf-ryr1", "RYR1", "chr19", 38440000, 38440000]]), - ("HLA-B", [["sf-hlab", "HLA-B", "chr6", 31353000, 31357000]]), - ("a gene alone", [["two-hits-g0999", "G0999", None, None, None]]), - ("a position under a de novo row", [["sf-pos", None, "chr3", 48211904, 48211904]])): + for name, spans in cases or (("the planted pair in KCNH2", None),) + CLOSED_CASES: r = copy.deepcopy(res) if spans is not None: - r["veil"]["spans"] = spans + act = [x for x in r.get("findings", []) if x.get("level") == "act"] + r["veil"] = dict(r.get("veil") or {}, spans=spans) + if act: + r["findings"] = [x for x in r["findings"] if x.get("level") != "act"] + [dict(act[0], id=s_[0]) for s_ in spans] with open(rj, "w") as f: json.dump(r, f) for fname, text in fams.items(): with open(fj, "w") as f: f.write(text) - views[f"{name}, {fname}"] = family_view(b, base) + views[f"{name}, {fname}"] = family_view(b, base, child) with open(rj, "w") as f: json.dump(res, f) + out = [f"the page never knew {child}'s results with {k!r}" for k, v in views.items() if v is None] first = next(iter(views)) - return [f"the page differs between {first!r} and {k!r}" for k, v in views.items() if v != views[first]] + return out + [f"the page differs between {first!r} and {k!r}" for k, v in views.items() if v != views[first]] def family_unknown(b, base, opens=True): """[problems] of the family page while it cannot read the son's results: a place of his open or a word of his - planted results shown; then (OPENS) the de novo box's own button opens his de novo rows and no other place.""" + planted results shown; then (OPENS) each box's own button, from a fresh page each, opens its own place and no + other.""" gene, hit, sv = planted_words() family_page(b, base) # A page still waiting for his results keeps everything closed anyway: the check is on the state it settles in. @@ -920,17 +946,29 @@ def family_unknown(b, base, opens=True): out += side_problems_of(b, True) if not opens or out: return out - open_box(b, "denovo") - shown = b.eval(FAMILY_SHOWN_JS) or "" - if boxes_of(b) != ["paint", "het", "side"] or (son_tables(b) or [])[1:] != [3] or GRANDPARENT in shown or PAIR_B in shown: - out.append(f"the de novo box opened another place: closed {boxes_of(b)}, tables {son_tables(b)}") - out += side_problems_of(b, True) + # What each place shows once open: the painting its grandparent, the de novo rows the planted hit (not the + # pair's second variant), the compound-het rows the pair's second variant, the side by side his cells. + for box in SON_BOXES: + family_page(b, base) + if not open_box(b, box): + out.append(f"no {box} box") + continue + shown = b.eval(FAMILY_SHOWN_JS) or "" + rest, tables = [x for x in SON_BOXES if x != box], son_tables(b) or [] + want = {"paint": GRANDPARENT in shown and PAIR_B not in shown and len(tables) == 1, + "denovo": GRANDPARENT not in shown and PAIR_B not in shown and tables[1:] == [3], + "het": GRANDPARENT not in shown and PAIR_B in shown and tables[1:] == [3], + "side": GRANDPARENT not in shown and PAIR_B not in shown and len(tables) == 1}[box] + if boxes_of(b) != rest or not want: + out.append(f"the {box} box opened another place, or not its own: closed {boxes_of(b)}, tables {tables}") + out += [f"after the {box} box: {x}" for x in side_problems_of(b, box != "side")] return out def unknown_map_problems(b, base): """[problems] of the family page whose son has his map as an image, with his results unreadable: 'Show the - rows' of a table opening the image, or 'Show the map' not opening it.""" + rows' of a table opening the image, 'Show the map' not opening it, or (from a fresh page) 'Show the map' opening + a table.""" family_page(b, base) img = "!!document.querySelector('details[data-child=\"DEMO-003\"] img.segments')" out = [] @@ -940,6 +978,9 @@ def unknown_map_problems(b, base): out.append("'Show the rows' opened the map too") if not open_box(b, "map") or not b.eval(img): out.append("'Show the map' did not open the map") + family_page(b, base) + if not open_box(b, "map") or not b.eval(img) or boxes_of(b) != ["denovo", "het", "side"] or son_tables(b): + out.append(f"'Show the map' opened another place, or not the map: closed {boxes_of(b)}, tables {son_tables(b)}") return out @@ -1001,6 +1042,85 @@ def couple_problems(b, base): return out +def nothing_closed_problems(b, base): + """[problems] of the family page whose son has nothing to act on (no veil): a box of his, or a place of his + closed: the painting's grandparent, his de novo and compound-het rows, his cells.""" + family_page(b, base) + kids = b.eval("document.documentElement.dataset.pgpKids || ''") or "" + shown = b.eval(FAMILY_SHOWN_JS) or "" + tables = son_tables(b) or [] + out = [] if re.search(r"\bDEMO-003:known\b", kids) else [f"the son's results never known: {kids!r}"] + if boxes_of(b) or GRANDPARENT not in shown or len(tables) != 3 or tables[1:] != [3, 3]: + out.append(f"a place closed with nothing to act on: closed {boxes_of(b)}, tables {tables}") + return out + side_problems_of(b, False) + + +def new_run_problems(b, base, fj, before, after): + """[problems]: with family.json BEFORE, the son's results opened from the page; then family.json AFTER (his run + in another folder) is fetched by the page itself, and his places must close again. The page asks every 30 s: + here every 0.5 s.""" + b.call("Page.enable", {}, b.session) # the script below runs only with the Page domain on + fast = b.call("Page.addScriptToEvaluateOnNewDocument", {"source": "(function () { var s = window.setInterval; " + "window.setInterval = function (f, t) { return s(f, t === 30000 ? 500 : t); }; }());"}, b.session) + try: + with open(fj, "w") as f: + f.write(before) + family_page(b, base) + out = [] + for _ in range(2): + open_box(b, "denovo") + if boxes_of(b): + out.append(f"the son's places not opened: {boxes_of(b)}") + with open(fj, "w") as f: + f.write(after) + moved = b.wait("var r = Array.prototype.some.call(document.querySelectorAll('a[href]'), function (a) { " + "return a.getAttribute('href').indexOf('-again/') >= 0; }); r && " + "/DEMO-003:known/.test(document.documentElement.dataset.pgpKids || '') && 'moved'", 10) + time.sleep(1.5) + if moved != "moved": + out.append("the page never fetched the moved run") + elif boxes_of(b) != SON_BOXES: + out.append(f"opened results stayed open for the son's new run: closed {boxes_of(b)}") + return out + finally: + b.call("Page.removeScriptToEvaluateOnNewDocument", {"identifier": (fast or {}).get("identifier", "")}, b.session) + b.call("Page.disable", {}, b.session) + + +def gene_cells(b, child): + """The values of CHILD's medicines and HLA cells in the side by side.""" + rows = side_rows(b) + members = b.eval("JSON.stringify(Array.prototype.map.call(document.querySelectorAll('#ch-side-by-side table.rows thead th'), " + "function (x) { return x.textContent; }).slice(2))") + members = json.loads(members) if members else [] + if child not in members: + return [] + i = members.index(child) + return [r[2 + i] for r in rows if r[0] in ("Medicines", "HLA")] + + +def unknown_couple_problems(b, base): + """[problems] of the couple fixture while the page cannot read DEMO-001's results: from a fresh page each, 'Show + the screen' opens the screen and leaves DEMO-001's cells closed, and 'Show the cells' opens the cells and leaves + the screen closed.""" + out, box = [], "couple:DEMO-001 DEMO-002" + screen = "!!document.querySelector('#ch-couples table.rows')" + for which in (box, "side"): + family_page(b, base) + if boxes_of(b, "DEMO-001") != [box, "side"] or b.eval(screen) or set(gene_cells(b, "DEMO-001")) != {"closed"}: + out.append(f"DEMO-001's places not all closed: {boxes_of(b, 'DEMO-001')}") + continue + if not open_box(b, which, "DEMO-001"): + out.append(f"no {which} box") + continue + cells = set(gene_cells(b, "DEMO-001")) + rest = [x for x in (box, "side") if x != which] + ok = (b.eval(screen) and cells == {"closed"}) if which == box else (not b.eval(screen) and cells and "closed" not in cells) + if boxes_of(b, "DEMO-001") != rest or not ok: + out.append(f"the {which} box opened another place, or not its own: closed {boxes_of(b, 'DEMO-001')}, cells {sorted(cells)[:3]}") + return out + + def family_veil_checks(b, base, out): """On the family page, while a child has a result to act on still closed, or the page cannot read the child's results, every place that could carry one closes for that child, the same whichever result it is; each known @@ -1070,7 +1190,8 @@ def planted_control(desc, a, z, problems): fams = {"a grandparent over the hit": planted, "a grandparent elsewhere": elsewhere} bad = invariance_problems(b, base, rj, res, fj, fams) check("browser: the family page is the same, byte for byte, whichever of the son's results is closed (a pair " - "in KCNH2, RYR1, HLA-B, a gene alone, a position) and wherever a grandparent's stretch lies", not bad, bad) + "in KCNH2, RYR1, HLA-B, a gene alone, a position, a copy-number span in SMN1, a CHIP call in TP53) and " + "wherever a grandparent's stretch lies, each view settled on knowing his results", not bad, bad) for desc, a, z in ( ("de novo rows closed only when a result lies under one", "var dshut = shutOf(c.child, 'denovo');", "var dshut = shutOf(c.child, 'denovo').filter(function (id) { return dn.some(function (r) { return " @@ -1083,7 +1204,13 @@ def planted_control(desc, a, z, problems): ("a side by side that closes only the closed gene's cells", "if (GENE_GROUPS[r.group] && shutBy[m] && shutBy[m].length) {", "if (GENE_GROUPS[r.group] && shutBy[m] && shutBy[m].length && (kids[m].spans || []).some(function (s) { " - "return s[1] === r.label; })) {")): + "return s[1] === r.label; })) {"), + ("a side by side whose cells stay open for a span wider than 10 kb (a copy-number region)", + "if (GENE_GROUPS[r.group] && shutBy[m] && shutBy[m].length) {", + "if (GENE_GROUPS[r.group] && shutBy[m] && shutBy[m].length && (kids[m].spans || []).every(function (s) { " + "return !(s[4] - s[3] > 10000); })) {"), + ("a page that never reads the son's results (every view the same, all closed as unreadable)", + "U.getJSON(url + 'results.json')", "U.getJSON(url + 'missing.json')")): planted_control(desc, a, z, lambda: invariance_problems(b, base, rj, res, fj, fams)) write(fj, planted) @@ -1091,12 +1218,17 @@ def planted_control(desc, a, z, problems): os.replace(rj, rj + ".away") try: bad = family_unknown(b, base) - check("browser: with the son's results.json unavailable every place of his stays closed, and the de novo " - "box's own button opens his de novo rows and no other place", not bad, bad) + check("browser: with the son's results.json unavailable every place of his stays closed, and each box's " + "own button (the painting's, the de novo, the compound-het, the side by side's) opens its own place " + "and no other", not bad, bad) planted_control("one opening shared by every box while the results cannot be read", "return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN];", "return opened[openKey(child, 'box:denovo')] ? [] : [UNKNOWN];", lambda: family_unknown(b, base)) + planted_control("'Show the grandparents' that opens the side by side while the results cannot be read", + "return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN];", + "return opened[openKey(child, box === 'side' ? 'box:paint' : 'box:' + box)] ? [] : [UNKNOWN];", + lambda: family_unknown(b, base)) finally: os.replace(rj + ".away", rj) write(rj, json.dumps({"schema_version": 1, "sample": SON, "chapters": [], @@ -1104,6 +1236,29 @@ def planted_control(desc, a, z, problems): bad = family_unknown(b, base, opens=False) check("browser: with the son's results answered as unreadable (a note, no findings) every place of his stays " "closed", not bad, bad) + write(rj, keep[rj][:5000]) + bad = family_unknown(b, base, opens=False) + check("browser: with the son's results.json cut short (a request that fails) every place of his stays closed", + not bad, bad) + planted_control("a failed request read as nothing closed", "cur.status = 'failed';", + "cur.status = 'known'; cur.spans = null; cur.acts = [];", lambda: family_unknown(b, base, opens=False)) + for desc, spans in (("[null]", [None]), ("[] while he has findings to act on", [])): + bad_spans = json.loads(keep[rj]) + bad_spans["veil"]["spans"] = spans + write(rj, json.dumps(bad_spans)) + bad = family_unknown(b, base, opens=False) + check(f"browser: with the son's veil.spans malformed ({desc}) every place of his stays closed", not bad, bad) + planted_control(f"malformed veil.spans ({desc}) read as given", "spans: spansOk ? v.spans : null", + "spans: Array.isArray(v.spans) ? v.spans : null", lambda: family_unknown(b, base, opens=False)) + clean = json.loads(keep[rj]) + clean["findings"] = [x for x in clean["findings"] if x.get("level") != "act"] + clean.pop("veil", None) + write(rj, json.dumps(clean)) + bad = nothing_closed_problems(b, base) + check("browser: with nothing of the son's to act on (no veil) every place of his is open, with no box", not bad, bad) + planted_control("results without veil.spans read as closed even with nothing to act on", + "if (!k || k.status !== 'known' || (!k.spans && k.acts.length)) {", + "if (!k || k.status !== 'known' || !k.spans) {", lambda: nothing_closed_problems(b, base)) nospans = json.loads(keep[rj]) nospans.pop("veil", None) write(rj, json.dumps(nospans)) @@ -1137,6 +1292,23 @@ def planted_control(desc, a, z, problems): lambda: two_children_problems(b, base)) write(fj, planted) + # The son's run moves to another folder (same results) while the page is open: what was opened closes again. + again = os.path.join(out, "runs", SON + "-again") + shutil.copytree(os.path.join(out, "runs", SON), again) + moved = json.loads(planted) + moved["runs"][SON]["url"] = moved["runs"][SON]["url"].replace(f"/{SON}/", f"/{SON}-again/") + try: + bad = new_run_problems(b, base, fj, planted, json.dumps(moved)) + check("browser: results the reader opened close again when the son's run changes while the page is open", + not bad, bad) + planted_control("an opened result kept per child only, not per run", + "function openKey(child, id) { return (kids[child] && kids[child].url) + '|' + child + '|' + id; }", + "function openKey(child, id) { return child + '|' + id; }", + lambda: new_run_problems(b, base, fj, planted, json.dumps(moved))) + finally: + shutil.rmtree(again, ignore_errors=True) + write(fj, planted) + # A partner of the couple screen who is also a child, with a closed result in a gene of the screen. famc = json.loads(keep[fj]) famc["state"]["members"].insert(0, {"sample": GRANDPARENT, "sex": "male", "father": None, "mother": None, @@ -1157,6 +1329,29 @@ def planted_control(desc, a, z, problems): "table, boxes, report link, the lead's words) until that result is opened from its box", not bad, bad) planted_control("a couple screen that ignores the veil", "var cs = coupleShut(f, c);", "var cs = [];", lambda: couple_problems(b, base)) + couple_cases = (("PRF1, a gene of the screen", [["two-hits-prf1", "PRF1", None, None, None]]), + ("RYR1, a gene the screen does not name", [["sf-ryr1", "RYR1", "chr19", 38440000, 38440000]])) + bad = invariance_problems(b, base, rj1, json.loads(keep[rj1]), fj, {"the couple": json.dumps(famc)}, + couple_cases, S) + check("browser: the family page is the same, byte for byte, whether the partner's closed result is in a gene " + "of the screen or not", not bad, bad) + planted_control("a couple screen closed only when the closed result is in one of its genes", + "var cs = coupleShut(f, c);", + "var cs = coupleShut(f, c).filter(function (x) { return (kids[x[0]].spans || []).some(function (s) { " + "return ((f.couples || {})[c.a + ' ' + c.b] || []).some(function (r) { return r.gene === s[1]; }); }); });", + lambda: invariance_problems(b, base, rj1, json.loads(keep[rj1]), fj, {"the couple": json.dumps(famc)}, + couple_cases, S)) + os.replace(rj1, rj1 + ".away") + try: + bad = unknown_couple_problems(b, base) + check("browser: with the partner's results unavailable, 'Show the screen' opens the screen and leaves the " + "partner's cells closed, and 'Show the cells' opens the cells and leaves the screen closed", not bad, bad) + planted_control("a couple screen that 'Show the cells' opens while the results cannot be read", + "return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN];", + "return opened[openKey(child, box.indexOf('couple:') === 0 ? 'box:side' : 'box:' + box)] ? [] : [UNKNOWN];", + lambda: unknown_couple_problems(b, base)) + finally: + os.replace(rj1 + ".away", rj1) write(rj1, keep[rj1]) # The drawing as an image (no stretches in family.json). @@ -1169,6 +1364,16 @@ def planted_control(desc, a, z, problems): "click, the map once both are opened", not bad, bad) planted_control("a map image that ignores the veil", "var shut = shutOf(c.child, 'map');", "var shut = [];", lambda: map_problems(b, base)) + mapfam = {"the map as an image": json.dumps(fam)} + bad = invariance_problems(b, base, rj, json.loads(keep[rj]), fj, mapfam) + check("browser: with the son's map as an image the family page is the same, byte for byte, whichever of his " + "results is closed", not bad, bad) + planted_control("a map image closed only when the closed result has a position", + "var shut = shutOf(c.child, 'map');", + "var shut = shutOf(c.child, 'map').filter(function (id) { return (kids[c.child].spans || []).some(" + "function (s) { return s[0] === id && s[2]; }); });", + lambda: invariance_problems(b, base, rj, json.loads(keep[rj]), fj, mapfam)) + write(fj, json.dumps(fam)) os.replace(rj, rj + ".away") try: bad = unknown_map_problems(b, base) @@ -1178,6 +1383,10 @@ def planted_control(desc, a, z, problems): "return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN];", "return opened[openKey(child, 'box:denovo')] ? [] : [UNKNOWN];", lambda: unknown_map_problems(b, base)) + planted_control("de novo rows that 'Show the map' opens", + "return opened[openKey(child, 'box:' + box)] ? [] : [UNKNOWN];", + "return opened[openKey(child, box === 'denovo' ? 'box:map' : 'box:' + box)] ? [] : [UNKNOWN];", + lambda: unknown_map_problems(b, base)) finally: os.replace(rj + ".away", rj) finally: From b170ad774ce341fd0b475fb4c0a7b1e47c919e8f Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:48:25 +0100 Subject: [PATCH 17/27] fix(live): keep a shown finding on the map when a closed result lies in its gene An open finding whose card the page shows was dropped from the map while a closed result sat inside its gene span, and came back when that result was opened: that placed the closed result inside the gene. A page finding now follows the page's own veil only; a row with no page finding (APOE) still follows its tag. A result opened by its own card now shows even when it overlaps another closed result. Also: APOE's chip and switch wait for the traits card in a replay, the chromosome's runs sentence waits for its step, and a failed reload empties the mark links and hides the tooltip. --- assets/live/map.js | 22 +++-- assets/live/person.js | 13 ++- docs/live-site.md | 4 +- tests/test_live_demo.py | 190 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 216 insertions(+), 13 deletions(-) diff --git a/assets/live/map.js b/assets/live/map.js index e7032c7c9..054471c96 100644 --- a/assets/live/map.js +++ b/assets/live/map.js @@ -105,7 +105,8 @@ // --- the figure --------------------------------------------------------------------------------------- // create({url, veil(id, gated) -> 'open'|'closed'|'opened', now(id) -> shown at this moment, open(id), - // close(id), stepDone(step) -> ended at this moment, fallback() -> rows}): one figure per page. The page calls refresh(key) on each of its redraws; a new key + // close(id), known(id) -> a finding of the page's results, stepDone(step) -> ended at this moment, + // fallback() -> rows}): one figure per page. The page calls refresh(key) on each of its redraws; a new key // fetches the document again and keeps the camera. function create(opts) { var M = null, key = null, failure = null, t0 = 0; @@ -157,12 +158,16 @@ // --- the veil: what this page keeps closed at this moment --- function stateOf(id) { return opts.veil(id, !!(M && M.gated[id])); } - // A row shows when its result is not closed and the page shows its finding at this moment (a replay + // A row shows when its own result is not closed and the page shows its finding at this moment (a replay // before the finding's step ended does not). - // A row under another result's tag also stays hidden while its own result is closed. + // A row that is a finding of the page (opts.known) follows the page's own veil only: the page already + // hides every finding it keeps closed (opts.now), and its card shows the rest. Hiding such a row for + // another result's tag would drop a finding whose card the page shows, and so place that closed result + // inside it. A row the page has no finding for (the map's APOE row) follows its tag too. function shownRow(r) { - return (!r.veil || stateOf(r.veil) !== 'closed') && (r.veil === r.id || M.closable.indexOf(r.id) < 0 || - stateOf(r.id) !== 'closed') && (!opts.now || opts.now(r.id)); + var own = M.closable.indexOf(r.id) < 0 || stateOf(r.id) !== 'closed'; + var tag = (opts.known && opts.known(r.id)) || !r.veil || stateOf(r.veil) !== 'closed'; + return own && tag && (!opts.now || opts.now(r.id)); } // The runs of homozygosity, as this page shows them now: a replay before their step ended has none yet. function rohLayer() { @@ -768,7 +773,8 @@ if (lv === 'genome') { var form = fs.length === 0 ? forms.none : fs.length === 1 ? forms.one : forms.many; add(narr, runs((form || []).map(function (x) { return { t: x.t.replace('{n}', String(fs.length)), b: x.b }; }))); - } else if (M.text.chrom && M.text.chrom[ch.n]) { + } else if (M.text.chrom && M.text.chrom[ch.n] && rohLayer() === M.layers.roh) { + // The chromosome's sentence is about its runs of homozygosity: a replay before their step ended has none. add(narr, runs(M.text.chrom[ch.n])); } var lay = M.layers.findings || {}; @@ -861,6 +867,8 @@ if (!M) { return; } var acts = [], gates = [], opened = []; M.closable.forEach(function (id) { + // A gated result (APOE) has a chip only once the page shows its card (a replay before its step has none). + if (M.gated[id] && opts.now && !opts.now(id)) { return; } var s = stateOf(id); if (s === 'closed') { (M.gated[id] ? gates : acts).push(id); } else if (s === 'opened') { opened.push(id); } }); @@ -1042,6 +1050,8 @@ narr.textContent = ''; layersBox.textContent = ''; chips.textContent = ''; // a chip drawn before the failure may name a result closed since + marks.textContent = ''; // and so may a mark link, hidden or not + hideTip(); } function load(k) { key = k; diff --git a/assets/live/person.js b/assets/live/person.js index 021cff5e1..04685da90 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -552,9 +552,16 @@ } // Whether the page shows a finding of the map at this moment: its card's steps are done (a replay draws // the run's end results from its first frame). A row with no finding of these results (the map's own APOE - // row) follows its card's fold instead. + // row) follows the card that holds its fold (the traits card): a replay before that card shows has no APOE. // A finding this page keeps closed is not shown either, whatever row tag it shares with another result. - function mapNow(id) { var f = findingById(id); var c = f && cardById(f.card); return !f || (!!(c && gate(c) === 'show') && !veiled(f)); } + function mapNow(id) { + var f = findingById(id); + if (!f) { var t = cards().filter(function (c) { return c.hidden; })[0]; return !!(t && gate(t) === 'show'); } + var c = cardById(f.card); + return !!(c && gate(c) === 'show') && !veiled(f); + } + // Whether a row of the map is a finding of these results: then the page's own veil alone decides it (map.js). + function mapKnown(id) { return !!findingById(id); } // Whether a step of the run has ended at this moment (a replay before it has not): the map's layers follow it. function stepDone(step) { var s = stepsBy()[step]; return !s || s.state === 'done'; } // Without the map (a browser with no DecompressionStream, or a map the server could not build): the findings @@ -884,7 +891,7 @@ // reader left it. It asks this page's veil for each closed row, and opens or closes a result through it. if (window.PGPMap) { if (!genomeMap) { - genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, stepDone: stepDone, open: function (id) { setShown(id, true); }, + genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, known: mapKnown, stepDone: stepDone, open: function (id) { setShown(id, true); }, close: function (id) { setShown(id, false); }, fallback: mapFallback }); } add(slot, genomeMap.node); diff --git a/docs/live-site.md b/docs/live-site.md index d5c57e616..70b462198 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 its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. +A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. A stopped run's page says so under the name and in the summary line, with the day it was last heard from, and warns that the report may be incomplete; a section still waiting for its step says the run stopped before that step ended. @@ -70,7 +70,7 @@ While the run goes, the page still opens on the report being written. A short ba The header search finds a gene or rsID across the results and opens the card that names it; on a page that keeps results closed it leaves out every hit that names one (above). One row of the results that the page cannot read (a field of the wrong type) costs that row only: it shows as a finding on its card, "A row of this section could not be read", at the highest level its rule can give (to act on for a rule that can give one, so a page that keeps results closed keeps that card closed too), the card whose table cannot be drawn says so, and every other card and finding is on the page. `#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. -**Figure 1, the genome map.** The 24 chromosomes as rows, to scale (23 for a person with no Y), each with its bands, the centromere as a waist and the stretches few reads can map hatched (centromeres, heterochromatin, the acrocentric arms). Each finding with a place sits on its chromosome as its level's shape, with its gene beside it when the name fits; runs of homozygosity of 5 Mb or more are a line under the row, shorter ones a faint band. A finding that names a gene but no position (a medicine gene, Lp(a)) sits on that gene's span; a finding with neither stays in its section only. Click a chromosome (or, with the map focused, press `[` or `]`) and it opens in place: an overview with the part in view, a ruler in Mb, the bands with their names, a findings lane with each gene and its level's words, and a lane for the runs of homozygosity, each run with its length. Drag to move along it, scroll or press the + and − buttons beside the zoom stops to zoom in (as far as about 10 Mb across a wide screen; closer views come later), and press Esc (or click Genome) to go back. Back in the browser returns to the view before, the whole genome when the address had no place. A dashed line marks the centre, and the margin says what is under it: the chromosome, the position and the band, the findings in view and the runs. The findings count, the chromosome sentences, the runs sentence and each finding's phrase come from the map document; the page adds its own short lines: a lane or stretch with no finding, the runs in view, how to move, and why a layer is not on the map. A replay draws the runs of homozygosity only once their step has ended. The address follows the map: `#genome`, `#chr7`, or `#chr7:117.4-137.4M`, and a link to one of them opens that view; a place the page cannot read shows a short note and leaves the map as it was. The map comes from `/runs//map.json`, which the map plugin (`bin/owngenome_live_map.py`) builds with `og map build` from the run folder and the results the page gets (`rust/crates/og-map/README.md` describes the document). It needs og on the host (`scripts/setup.sh --og`); without it, or in a browser that cannot unpack the file (no `DecompressionStream`), the figure says so and lists the findings with a place in a table instead. A layer whose file is missing or cannot be read says so in words, never as a blank lane. +**Figure 1, the genome map.** The 24 chromosomes as rows, to scale (23 for a person with no Y), each with its bands, the centromere as a waist and the stretches few reads can map hatched (centromeres, heterochromatin, the acrocentric arms). Each finding with a place sits on its chromosome as its level's shape, with its gene beside it when the name fits; runs of homozygosity of 5 Mb or more are a line under the row, shorter ones a faint band. A finding that names a gene but no position (a medicine gene, Lp(a)) sits on that gene's span; a finding with neither stays in its section only. Click a chromosome (or, with the map focused, press `[` or `]`) and it opens in place: an overview with the part in view, a ruler in Mb, the bands with their names, a findings lane with each gene and its level's words, and a lane for the runs of homozygosity, each run with its length. Drag to move along it, scroll or press the + and − buttons beside the zoom stops to zoom in (as far as about 10 Mb across a wide screen; closer views come later), and press Esc (or click Genome) to go back. Back in the browser returns to the view before, the whole genome when the address had no place. A dashed line marks the centre, and the margin says what is under it: the chromosome, the position and the band, the findings in view and the runs. The findings count, the chromosome sentences, the runs sentence and each finding's phrase come from the map document; the page adds its own short lines: a lane or stretch with no finding, the runs in view, how to move, and why a layer is not on the map. A replay draws the runs of homozygosity, and says the sentences about them, only once their step has ended. The address follows the map: `#genome`, `#chr7`, or `#chr7:117.4-137.4M`, and a link to one of them opens that view; a place the page cannot read shows a short note and leaves the map as it was. The map comes from `/runs//map.json`, which the map plugin (`bin/owngenome_live_map.py`) builds with `og map build` from the run folder and the results the page gets (`rust/crates/og-map/README.md` describes the document). It needs og on the host (`scripts/setup.sh --og`); without it, or in a browser that cannot unpack the file (no `DecompressionStream`), the figure says so and lists the findings with a place in a table instead. A layer whose file is missing or cannot be read says so in words, never as a blank lane. **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. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 65a965532..171c1c6f0 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -75,6 +75,7 @@ import base64 import copy import gzip +import hashlib import json import os import shutil @@ -1475,6 +1476,158 @@ def overlap_problems(b, base, out): fh.write(keep) +# The figure as a reader gets it before any click: its HTML (the chips aside, which count the closed results +# on purpose, and the timing attributes), its marks, margin and layers, and a hash of the canvas's pixels. +FIGURE_JS = r"""JSON.stringify((function () { + var f = document.getElementById('genome-map'); + if (!f) { return null; } + var c = f.cloneNode(true); + Array.prototype.forEach.call(c.querySelectorAll('.gm-chips'), function (x) { x.textContent = ''; }); + c.removeAttribute('data-pgp-map-parse-ms'); + c.removeAttribute('data-pgp-map-paint-ms'); + return { html: c.outerHTML, canvas: f.querySelector('.gm-canvas').toDataURL() }; +}()))""" + + +def plant_son(out, extra, og): + """The son's results.json with the findings EXTRA added and his map.json built again from it by og map + build, as the map plugin builds it: og tags each row itself. Returns what to write back afterwards.""" + son = OTHERS[1] + rj, mj = (os.path.join(out, "runs", son, n) for n in ("results.json", "map.json")) + keep = {} + for p in (rj, mj): + with open(p, "rb") as fh: + keep[p] = fh.read() + res = json.loads(keep[rj]) + res["findings"] = res["findings"] + [dict(f) for f in extra] + sec = {"act": 1, "medicine": 2, "carrier": 3, "review": 4} + for f in extra: # each in its level's section, as live_results lists them: its card shows on the page + for j in res["journal"]: + if j["n"] == sec[f["level"]]: + j["findings"] = j["findings"] + [f["id"]] + with open(rj, "w") as fh: + json.dump(res, fh) + root = os.path.join(os.path.dirname(out), "root") + doc = build_live_demo.og_map(og, ["build", "--root", root, "--run", son, "--assets", os.path.join(REPO, "assets"), + "--cache-dir", os.path.join(root, ".owngenome-live", "map")], + json.dumps({"results": res}).encode()) + with open(mj, "wb") as fh: + fh.write(doc) + return keep + + +def unplant(keep): + for p, data in keep.items(): + with open(p, "wb") as fh: + fh.write(data) + + +def row_tag(out, fid): + """The id whose tag og put on FID's row of the son's map, or None.""" + son = OTHERS[1] + doc = read_map(os.path.join(out, "runs", son, "map.json")) + f = doc["per"][son]["findings"] + if fid not in f["id"]: + return None + v = f["veil"][f["id"].index(fid)] + return doc["dict"]["veil"][v] if v >= 0 else None + + +def son_figure(b, base, hash_=""): + """The son's map before any click: (the map's facts, the figure with its canvas hashed).""" + c = map_page(b, base, OTHERS[1], hash_) + fig = json.loads(b.eval(FIGURE_JS) or "null") or {} + fig["canvas"] = hashlib.sha256((fig.get("canvas") or "").encode()).hexdigest()[:12] + return c, fig + + +def act(fid, gene, viewer, title): + return {"id": fid, "level": "act", "card": "clinical-filter", "gene": gene, "viewer": viewer, "title": title, + "detail": f"{gene} {viewer}", "text": f"{title}, planted."} + + +# An open finding over a closed result whose card names another gene: og tags the open finding's row (the +# whole gene span) with the closed result, while the page shows its card. A map that hides that row only +# while the result is closed places the closed result inside that gene. Each planting: (what, the closed +# results, the open findings in both pages, the open finding to watch, chromosome). +UNDER = (("a gene-only review finding (CFTR) over a closed deletion naming another gene", + [act("pl-iota", "PLIOTA", "chr7:117400000-117700000", "Planted iota deletion")], + [{"id": "pl-kappa", "level": "review", "card": "exomiser", "gene": "CFTR", "title": "Planted kappa review", + "detail": "CFTR", "text": "A planted review finding that names CFTR only."}], "pl-kappa", "chr7"), + ("the page's own CYP2D6 medicine finding over a closed variant naming another gene", + [act("pl-theta", "PLTHETA", "chr22:42125000", "Planted theta result")], [], "pgx-cyp2d6", "chr22")) + + +def under_problems(b, base, out, og): + """For each planting of UNDER: the son's map before any click is the same, figure HTML, canvas, marks, + margin and layers, with the closed result and without it, at the genome level and on its chromosome; and + the open finding is drawn in both.""" + bad = [] + for what, closed, opened, watch, chrom in UNDER: + got = {} + for name, extra in (("without", opened), ("with", opened + closed)): + keep = plant_son(out, extra, og) + try: + if name == "with" and row_tag(out, watch) != closed[0]["id"]: + bad.append(f"{what}: og did not tag {watch}'s row with {closed[0]['id']} ({row_tag(out, watch)}): " + "the planting tests nothing") + got[name] = [son_figure(b, base, h) for h in ("", "#" + chrom)] + with open(os.path.join(out, "runs", OTHERS[1], "results.json")) as fh: + title = [f["title"] for f in json.load(fh)["findings"] if f["id"] == watch][0] + if not b.eval("document.body.innerText.indexOf(%s) >= 0" % json.dumps(title)): + bad.append(f"{what}: the page does not show {watch}'s card ({title}): the planting tests nothing") + finally: + unplant(keep) + for (c0, f0), (c1, f1), lv in zip(got["without"], got["with"], ("genome", chrom)): + if watch not in [m["id"] for m in c0["marks"]]: + bad.append(f"{what}, {lv}: {watch} is not on the map even with no closed result") + if f0 != f1: + diff = [k for k in ("html", "canvas") if f0.get(k) != f1.get(k)] + bad.append(f"{what}, {lv}: the figure differs with the closed result ({diff}): marks " + f"{[m['id'] for m in c0['marks']]} vs {[m['id'] for m in c1['marks']]}") + return bad + + +def second_open_problems(b, base, out, og): + """Two overlapping closed results on the son's page (og tags the second with the first): opening the + second by its own card draws exactly it on the map, never the first.""" + two = [act("pl-alpha", "PLALPHA", "chr17:43050000-43060000", "Planted alpha result"), + act("pl-beta", "PLBETA", "chr17:43055000", "Planted beta result")] + keep = plant_son(out, two, og) + try: + if row_tag(out, "pl-beta") != "pl-alpha": + return [f"og did not tag pl-beta's row with pl-alpha ({row_tag(out, 'pl-beta')}): the planting tests nothing"] + c = map_page(b, base, OTHERS[1], "#chr17") + ids = [m["id"] for m in c["marks"]] + if "pl-alpha" in ids or "pl-beta" in ids: + return [f"closed: marks {ids}"] + b.eval("document.querySelector('#f-pl-beta .veil button').click(); true") + c = map_now(b) + ids = [m["id"] for m in c["marks"]] + bad = [] + if "pl-beta" not in ids: + bad.append(f"opened by its card, pl-beta is not on the map: marks {ids}, chips {c['chips']}") + if "pl-alpha" in ids or "PLALPHA" in c["text"]: + bad.append(f"opening pl-beta shows pl-alpha: marks {ids}") + return bad + finally: + unplant(keep) + + +def replay_apoe_problems(b, base): + """DEMO-001 at the first frame of its replay, before the traits step: the map has no APOE chip, no + switch to open it and no APOE mark, as its card has no fold yet.""" + map_page(b, base, S) + b.eval("var s = document.getElementById('replay-slider'); s.value = 0; s.dispatchEvent(new Event('input')); true") + c = map_now(b) + bad = [] + if b.eval("!!document.querySelector('details[data-gate=apoe]')"): + bad.append("the traits card has its APOE fold at the first frame: the check tests nothing") + if any("APOE" in t for t in c["chips"]) or any(m["id"] == "apoe" for m in c["marks"]): + bad.append(f"before the traits step: chips {c['chips']}, marks {[m['id'] for m in c['marks']]}") + return bad + + def missing_layer_problems(b, base, out): """A map whose ROH layer is missing says so in words, in the margin, never a blank.""" path = os.path.join(out, "runs", S, "map.json") @@ -1604,15 +1757,48 @@ def map_checks(b, base, out): mjs = os.path.join(out, "assets", "map.js") with open(mjs) as fh: keep_mjs = fh.read() - own = "(r.veil === r.id || M.closable.indexOf(r.id) < 0 ||\n stateOf(r.id) !== 'closed') && " + own = "var own = M.closable.indexOf(r.id) < 0 || stateOf(r.id) !== 'closed';" try: with open(mjs, "w") as fh: - fh.write(keep_mjs.replace(own, "")) + fh.write(keep_mjs.replace(own, "var own = true;")) control("map.js whose rows ask only their tag, never their own result", keep_mjs.count(own) != 1 or not overlap_problems(b, base, out)) finally: with open(mjs, "w") as fh: fh.write(keep_mjs) + og = og_binary() or build_live_demo.OG + bad = under_problems(b, base, out, og) + check("map: an open finding over a closed result naming another gene stays on the map; before any click the " + "figure is the same with the closed result and without it", not bad, bad) + bad = second_open_problems(b, base, out, og) + check("map: the second of two overlapping closed results, opened by its own card, is drawn, and the first " + "is not", not bad, bad) + known = "(opts.known && opts.known(r.id)) || " + try: + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(known, "")) + control("map.js whose page findings follow another result's tag", keep_mjs.count(known) != 1 or + not under_problems(b, base, out, og)) + control("map.js whose page findings follow another result's tag (the second result opened)", + keep_mjs.count(known) != 1 or not second_open_problems(b, base, out, og)) + finally: + with open(mjs, "w") as fh: + fh.write(keep_mjs) + bad = replay_apoe_problems(b, base) + check("map: before the traits step a replay has no APOE chip, switch or mark", not bad, bad) + gated = "if (M.gated[id] && opts.now && !opts.now(id)) { return; }" + try: + with open(js, "w") as fh: + fh.write(keep_js.replace("if (!f) { var t = cards()", "if (!f) { return true; var t = cards()")) + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(gated, "")) + control("person.js and map.js that show APOE's chip and row before its card", keep_mjs.count(gated) != 1 or + not replay_apoe_problems(b, base)) + finally: + with open(js, "w") as fh: + fh.write(keep_js) + with open(mjs, "w") as fh: + fh.write(keep_mjs) bad = missing_layer_problems(b, base, out) check("map: a missing layer says why in words, never a blank", not bad, bad) bad = fallback_problems(b, base, out) From ae3716fac9de6594007eaee757593bd37b1c6980 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:44:01 +0100 Subject: [PATCH 18/27] fix(live): count every closed result on the map chip and stop drawing after a failed reload The dashed chip now counts the results to act on the page keeps closed, so it no longer tells whether a closed result has a place on the map. Open opens the placed ones first, then the next closed result in its section. A failed reload drops the frame and margin the last refresh scheduled, so the old document is not drawn again. New planted checks, each with a control watched red: a finding the server closes by name and a tagged map row the page has no finding for stay hidden; the chip count is the same with a closed result that has a place and one that has none; the runs sentence waits for its step; nothing is drawn after a failed reload. plant_son now computes results.veil.findings with live_results.veil, as the server does. --- assets/live/map.js | 31 +++-- assets/live/person.js | 7 +- docs/live-site.md | 4 +- rust/crates/og-map/README.md | 2 +- tests/test_live_demo.py | 219 ++++++++++++++++++++++++++++++++++- 5 files changed, 247 insertions(+), 16 deletions(-) diff --git a/assets/live/map.js b/assets/live/map.js index 054471c96..a9448a17e 100644 --- a/assets/live/map.js +++ b/assets/live/map.js @@ -105,8 +105,9 @@ // --- the figure --------------------------------------------------------------------------------------- // create({url, veil(id, gated) -> 'open'|'closed'|'opened', now(id) -> shown at this moment, open(id), - // close(id), known(id) -> a finding of the page's results, stepDone(step) -> ended at this moment, - // fallback() -> rows}): one figure per page. The page calls refresh(key) on each of its redraws; a new key + // close(id), known(id) -> a finding of the page's results, closed() -> the ids of the results to act on the + // page keeps closed at this moment, stepDone(step) -> ended at this moment, fallback() -> rows}): one figure + // per page. The page calls refresh(key) on each of its redraws; a new key // fetches the document again and keeps the camera. function create(opts) { var M = null, key = null, failure = null, t0 = 0; @@ -383,7 +384,7 @@ function draw() { raf = 0; - if (!M) { return; } + if (!M || failure) { return; } // a failed reload leaves the old document undrawn var t = performance.now(); if (!size()) { return; } // the page may have changed width since the last frame (a phone turned, a fold opened) step(); @@ -597,7 +598,17 @@ function noFindingHere() { return closedAny() ? 'No open finding with a place on this chromosome.' : 'No finding with a place on this chromosome.'; } - function closedAny() { return M.closable.some(function (id) { return stateOf(id) === 'closed'; }); } + function closedAny() { return closedActs().length > 0 || M.closable.some(function (id) { return stateOf(id) === 'closed'; }); } + // The results to act on this page keeps closed, in the order Open opens them: the ones with a place on the + // map first, in map order, then the rest of the page's closed results, which have no place to draw. Its + // length is what the chip counts, so the count never tells whether a closed result has a place. + function closedActs() { + var page = opts.closed ? opts.closed() : null; + var placed = M.closable.filter(function (id) { + return !M.gated[id] && stateOf(id) === 'closed' && (!page || page.indexOf(id) >= 0); + }); + return page ? placed.concat(page.filter(function (id) { return placed.indexOf(id) < 0; })) : placed; + } function layerWords(lay) { if (lay.state === 'missing') { return 'Not on the map: ' + (lay.note || ('step ' + lay.step + ' wrote no ' + (lay.source || 'file') + '.')); } if (lay.state === 'unreadable') { return 'Not on the map: ' + (lay.source || '') + ' could not be read' + (lay.note ? ' (' + lay.note + ')' : '') + '.'; } @@ -630,7 +641,7 @@ cam.k += (tgt.k - cam.k) * (1 - Math.exp(-dt / 95)); if (!moving()) { cam.c = tgt.c; cam.ls = tgt.ls; cam.k = tgt.k; } } - function schedule() { if (!raf) { raf = window.requestAnimationFrame(function () { last = last || performance.now(); draw(); }); } } + function schedule() { if (!raf && !failure) { raf = window.requestAnimationFrame(function () { last = last || performance.now(); draw(); }); } } function clampTgt() { var ch = M.chroms[tgt.ci], span = clamp(Math.exp(tgt.ls), minSpan(ch.len), ch.len); tgt.ls = Math.log(span); @@ -738,7 +749,7 @@ settleT = window.setTimeout(describe, 140); } function describe() { - if (!M) { return; } + if (!M || failure) { return; } var lv = level(), fs = drawn(), ch = chrom(); fig.setAttribute('data-level', lv); fig.setAttribute('data-chrom', lv === 'genome' ? '' : ch.n); @@ -865,12 +876,12 @@ function drawChips() { chips.textContent = ''; if (!M) { return; } - var acts = [], gates = [], opened = []; + var acts = closedActs(), gates = [], opened = []; M.closable.forEach(function (id) { // A gated result (APOE) has a chip only once the page shows its card (a replay before its step has none). if (M.gated[id] && opts.now && !opts.now(id)) { return; } var s = stateOf(id); - if (s === 'closed') { (M.gated[id] ? gates : acts).push(id); } else if (s === 'opened') { opened.push(id); } + if (s === 'closed') { if (M.gated[id]) { gates.push(id); } } else if (s === 'opened') { opened.push(id); } }); // Why it is closed: said beside the count, left out on a phone (the card says it too). function why() { return el('span', { 'class': 'gm-why' }, ' · Not drawn until opened on purpose'); } @@ -1035,6 +1046,10 @@ // --- loading --- function fail(why, words) { failure = why; + // The frame and the margin the last refresh scheduled would draw the old document again: drop them. + window.cancelAnimationFrame(raf); + raf = 0; + window.clearTimeout(settleT); fig.setAttribute('data-pgp-map', why); stage.hidden = true; marks.hidden = true; diff --git a/assets/live/person.js b/assets/live/person.js index 04685da90..f00936988 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -560,6 +560,11 @@ var c = cardById(f.card); return !!(c && gate(c) === 'show') && !veiled(f); } + // The results to act on this page keeps closed at this moment, in the page's order: the map's chip counts + // them, whether or not they have a place on the map. + function mapClosed() { + return findingsNow().filter(function (f) { return f.level === 'act' && veiled(f); }).map(function (f) { return f.id; }); + } // Whether a row of the map is a finding of these results: then the page's own veil alone decides it (map.js). function mapKnown(id) { return !!findingById(id); } // Whether a step of the run has ended at this moment (a replay before it has not): the map's layers follow it. @@ -891,7 +896,7 @@ // reader left it. It asks this page's veil for each closed row, and opens or closes a result through it. if (window.PGPMap) { if (!genomeMap) { - genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, known: mapKnown, stepDone: stepDone, open: function (id) { setShown(id, true); }, + genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, known: mapKnown, closed: mapClosed, stepDone: stepDone, open: function (id) { setShown(id, true); }, close: function (id) { setShown(id, false); }, fallback: mapFallback }); } add(slot, genomeMap.node); diff --git a/docs/live-site.md b/docs/live-site.md index 70b462198..1fd6d8c42 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 its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. +A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens the closed results with a place first, in map order; after them it opens the next closed result in its section, and the map then draws nothing new, as that result has no place on it. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. A stopped run's page says so under the name and in the summary line, with the day it was last heard from, and warns that the report may be incomplete; a section still waiting for its step says the run stopped before that step ended. @@ -70,7 +70,7 @@ While the run goes, the page still opens on the report being written. A short ba The header search finds a gene or rsID across the results and opens the card that names it; on a page that keeps results closed it leaves out every hit that names one (above). One row of the results that the page cannot read (a field of the wrong type) costs that row only: it shows as a finding on its card, "A row of this section could not be read", at the highest level its rule can give (to act on for a rule that can give one, so a page that keeps results closed keeps that card closed too), the card whose table cannot be drawn says so, and every other card and finding is on the page. `#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. -**Figure 1, the genome map.** The 24 chromosomes as rows, to scale (23 for a person with no Y), each with its bands, the centromere as a waist and the stretches few reads can map hatched (centromeres, heterochromatin, the acrocentric arms). Each finding with a place sits on its chromosome as its level's shape, with its gene beside it when the name fits; runs of homozygosity of 5 Mb or more are a line under the row, shorter ones a faint band. A finding that names a gene but no position (a medicine gene, Lp(a)) sits on that gene's span; a finding with neither stays in its section only. Click a chromosome (or, with the map focused, press `[` or `]`) and it opens in place: an overview with the part in view, a ruler in Mb, the bands with their names, a findings lane with each gene and its level's words, and a lane for the runs of homozygosity, each run with its length. Drag to move along it, scroll or press the + and − buttons beside the zoom stops to zoom in (as far as about 10 Mb across a wide screen; closer views come later), and press Esc (or click Genome) to go back. Back in the browser returns to the view before, the whole genome when the address had no place. A dashed line marks the centre, and the margin says what is under it: the chromosome, the position and the band, the findings in view and the runs. The findings count, the chromosome sentences, the runs sentence and each finding's phrase come from the map document; the page adds its own short lines: a lane or stretch with no finding, the runs in view, how to move, and why a layer is not on the map. A replay draws the runs of homozygosity, and says the sentences about them, only once their step has ended. The address follows the map: `#genome`, `#chr7`, or `#chr7:117.4-137.4M`, and a link to one of them opens that view; a place the page cannot read shows a short note and leaves the map as it was. The map comes from `/runs//map.json`, which the map plugin (`bin/owngenome_live_map.py`) builds with `og map build` from the run folder and the results the page gets (`rust/crates/og-map/README.md` describes the document). It needs og on the host (`scripts/setup.sh --og`); without it, or in a browser that cannot unpack the file (no `DecompressionStream`), the figure says so and lists the findings with a place in a table instead. A layer whose file is missing or cannot be read says so in words, never as a blank lane. +**Figure 1, the genome map.** The 24 chromosomes as rows, to scale (23 for a person with no Y), each with its bands, the centromere as a waist and the stretches few reads can map hatched (centromeres, heterochromatin, the acrocentric arms). Each finding with a place sits on its chromosome as its level's shape, with its gene beside it when the name fits; runs of homozygosity of 5 Mb or more are a line under the row, shorter ones a faint band. A finding that names a gene but no position (a medicine gene, Lp(a)) sits on that gene's span; a finding with neither stays in its section only. Click a chromosome (or, with the map focused, press `[` or `]`) and it opens in place: an overview with the part in view, a ruler in Mb, the bands with their names, a findings lane with each gene and its level's words, and a lane for the runs of homozygosity, each run with its length. Drag to move along it, scroll or press the + and − buttons beside the zoom stops to zoom in (as far as about 10 Mb across a wide screen; closer views come later), and press Esc (or click Genome) to go back. Back in the browser returns to the view before, the whole genome when the address had no place. A dashed line marks the centre, and the margin says what is under it: the chromosome, the position and the band, the findings in view and the runs. The findings count, the chromosome sentences, the runs sentence and each finding's phrase come from the map document; the page adds its own short lines: a lane or stretch with no finding, the runs in view, how to move, and why a layer is not on the map. A replay draws the runs of homozygosity, and says the sentences about them, only once their step has ended. The address follows the map: `#genome`, `#chr7`, or `#chr7:117.4-137.4M`, and a link to one of them opens that view; a place the page cannot read shows a short note and leaves the map as it was. The map comes from `/runs//map.json`, which the map plugin (`bin/owngenome_live_map.py`) builds with `og map build` from the run folder and the results the page gets (`rust/crates/og-map/README.md` describes the document). It needs og on the host (`scripts/setup.sh --og`); without it, or in a browser that cannot unpack the file (no `DecompressionStream`), the figure says so and lists the findings with a place in a table instead. A map that fails to load again after it was drawn says so the same way and draws nothing more from the map it had. A layer whose file is missing or cannot be read says so in words, never as a blank lane. **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. diff --git a/rust/crates/og-map/README.md b/rust/crates/og-map/README.md index 8d84d50dc..eee8c3f5d 100644 --- a/rust/crates/og-map/README.md +++ b/rust/crates/og-map/README.md @@ -74,7 +74,7 @@ One gzip member with mtime 0 and no file name, holding JSON written as Python's - `ref.chroms`: chr1-22, X and Y. `len` is the chromosome's last band end, `cen` the span of its `acen` bands, `masked` its `acen`, `gvar` and `stalk` bands with touching neighbours merged. - `L`, a layer entry: `state` (`ok`, `missing` or `unreadable`), `step`, `step_name`, `source` (the file read or looked for, relative to GENOME_DIR), `note` (why it is missing, or `file:line: reason` when unreadable), `count` (rows on the map), `totals` (`all` rows read and `kept` rows on the map, when the file was read), `caveat`, and `empty` (the sentence for a read file with no rows). A missing input is `missing`, an input that cannot be read is `unreadable`, and a read input with no rows is `ok` with `count` 0: three answers, never a blank lane. - `findings`: one flat column per field, rows sorted by chromosome then start. A finding's `viewer` (`chrom:pos` or `chrom:start-end`, 1-based, `chr` optional) places it: a position gives `pos` and `[pos - 1, pos)`, a span gives `[start - 1, end)` and `pos` null. Without a usable `viewer`, a finding that names a gene sits on that gene's span from the GENCODE genes file (the first in map order when a name is on several chromosomes). A finding with neither is not a row. `gate` is set for a finding the page keeps behind its own warning: the traits card holds APOE that way, so the map adds the finding `apoe` on the APOE gene, with `gate` `apoe`; the hidden text, which names the genotype, never enters the document. `zyg` (`het`, `hom`, `hemi` or `unknown`) comes from the finding's own `Genotype` evidence; no VCF is read. `hgvs` is its `Variant` evidence. `copy` is null until the family map. `href` is the finding's page relative to the person's page, `reads` the place as the reads viewer takes it (1-based), `phrase` the sentence the map shows. `veil` is the index in `dict.veil` of the first closable finding (in `dict.veil` order) whose span the row overlaps: its own for a closable finding with no earlier overlap, else -1. So an open finding over a closable one's span is tagged too. The layer's `count` and `totals.kept` are the rows whose `veil` is -1; `totals.all` adds the findings that are not closable and have no place, and the `note` counts those ("N findings with no place on the map stay on the page"). -- **The veil.** The document is the same for every reader. Whether a page is closed is the page's own rule; the document says only what a closed result could reveal. Closable findings are those of level `act` and every gated one (APOE closes on every page, an adult's too). Each closable finding with a place veils its span, and `dict.veil` lists their ids: gated ones first, then the act ones in the page's order. A row of a layer that can be a finding's own source (structural variants, mosaic calls and ClinVar hits, as their layers arrive), and every findings row, that overlaps a veiled span carries the index of the first such finding in `dict.veil` in a `veil` column, else -1. A row holds one tag only. Gated findings come first, so a row over both APOE and an act finding carries APOE, which is closed on every page; a row over two act findings carries the first, and both close under the same rule. The page hides a tagged row while its result is closed. Counts, totals and density bins cover untagged rows only, so no number counts a closed row. Runs of homozygosity and bands are never a finding's source and carry no tag. +- **The veil.** The document is the same for every reader. Whether a page is closed is the page's own rule; the document says only what a closed result could reveal. Closable findings are those of level `act` and every gated one (APOE closes on every page, an adult's too). Each closable finding with a place veils its span, and `dict.veil` lists their ids: gated ones first, then the act ones in the page's order. A row of a layer that can be a finding's own source (structural variants, mosaic calls and ClinVar hits, as their layers arrive), and every findings row, that overlaps a veiled span carries the index of the first such finding in `dict.veil` in a `veil` column, else -1. A row holds one tag only. Gated findings come first, so a row over both APOE and an act finding carries APOE, which is closed on every page; a row over two act findings carries the first, and both close under the same rule. The page decides its own findings with its own veil, and the tag binds only the rows that are not one of the page's findings (APOE, or a row the page's results do not have yet): the page hides such a row while its result is closed. Counts, totals and density bins cover untagged rows only, so no number counts a closed row. Runs of homozygosity and bands are never a finding's source and carry no tag. - `roh`: the `RG` rows of step 11's `bcftools roh` text (`{run}/vcf/{run}_roh.txt`, else `{run}/roh/{run}_roh.txt`). bcftools writes 1-based inclusive positions, so `s = start - 1` and `e = end`. `ST` lines are skipped; rows on chrM and other contigs count in `totals.all` only. `markers` and `q` are bcftools' columns 7 and 8. `art` indexes `dict.art`: `one X` on a male's chrX, `one Y` on a male's chrY, else `centromere` when more than half the run lies in masked spans. An unknown sex sets neither flag. `long` is 1 for a run of 5 Mb or more with no `art`. - `roh_bins`: bases covered by any run per 1 Mb bin, for each chromosome with a run. - `text`: the sentences the page shows, in pieces; `b` marks bold words. `text.findings` is the count of findings on the map in three forms (`none`, `one`, `many`): the page counts the findings rows whose `veil` is -1 plus the tagged rows whose result is open, picks the form and puts the number in place of `{n}`. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 171c1c6f0..11811cf33 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -1378,8 +1378,12 @@ def leaks(c): return [f"the son's map: {c['state']}"] if leaks(c): bad.append(f"closed: the map shows {leaks(c)}") - if not any("1 result closed" in t for t in c["chips"]): - bad.append(f"closed: no chip counting it: {c['chips']}") + # The chip counts every result to act on the page keeps closed, with a place on the map or not. + want = b.eval("new Set(Array.prototype.filter.call(document.querySelectorAll('.finding[data-level=act]'), " + "function (a) { return a.querySelector('.veil'); }).map(function (a) { return a.dataset.finding; })).size") + label = "1 result closed" if want == 1 else f"{want} results closed" + if not want or not any(t.startswith(label + " ") for t in c["chips"]): + bad.append(f"closed: no chip counting the page's {want} closed results: {c['chips']}") b.eval("document.querySelector('.gm-chip[data-closed=act] button').click(); true") c = map_now(b) if not b.eval("!!document.querySelector('.gm-chip[data-closed=act] .gm-confirm')") or leaks(c): @@ -1490,8 +1494,9 @@ def overlap_problems(b, base, out): def plant_son(out, extra, og): - """The son's results.json with the findings EXTRA added and his map.json built again from it by og map - build, as the map plugin builds it: og tags each row itself. Returns what to write back afterwards.""" + """The son's results.json with the findings EXTRA added, its findings' veil tags (results.veil.findings) + computed again by live_results.veil as the server computes them, and his map.json built again from it by og + map build, as the map plugin builds it: og tags each row itself. Returns what to write back afterwards.""" son = OTHERS[1] rj, mj = (os.path.join(out, "runs", son, n) for n in ("results.json", "map.json")) keep = {} @@ -1505,6 +1510,8 @@ def plant_son(out, extra, og): for j in res["journal"]: if j["n"] == sec[f["level"]]: j["findings"] = j["findings"] + [f["id"]] + tags = live_results.veil({}, res["findings"], []).get("findings") or {} + res["veil"] = dict(res.get("veil") or {}, findings=dict((res.get("veil") or {}).get("findings") or {}, **tags)) with open(rj, "w") as fh: json.dump(res, fh) root = os.path.join(os.path.dirname(out), "root") @@ -1614,6 +1621,157 @@ def second_open_problems(b, base, out, og): unplant(keep) +# A finding the server tags with a closed result because it names its gene (results.veil.findings: a symptom +# ranking), once with og's tag on its row too (CFTR, inside the deletion's span) and once with none (BRCA1). +NAMED = (act("pl-x", "PLXGENE", "chr7:117400000-117700000", "Planted x deletion"), + {"id": "pl-q", "level": "review", "card": "exomiser", "gene": "CFTR", "title": "Symptom ranking: PLXGENE ranks 1", + "detail": "CFTR", "text": "A planted ranking that names the deletion's gene."}, + {"id": "pl-r", "level": "review", "card": "exomiser", "gene": "BRCA1", "title": "Symptom ranking: PLXGENE ranks 2", + "detail": "BRCA1", "text": "A planted ranking that names the deletion's gene."}) +# A row of the map with no finding in results.json (a newer finding the map holds before the page has it), +# on the closed deletion's place and with its tag. +GHOST = {"id": "pl-ghost", "level": "review", "gene": "GHOSTGENE", "title": "Ghost review", "href": "finding/pl-ghost/", + "phrase": "Ghost review phrase."} + + +def named_problems(b, base, out, og): + """On the son's closed page, with the planted deletion pl-x closed: the findings the server closes with it + (pl-q, pl-r) and a tagged map row the page has no finding for (pl-ghost) are in no mark, sentence, link, + title or address, and the figure is the one of the page without them; opening pl-x by its card draws pl-x, + pl-q and pl-r.""" + son = OTHERS[1] + _, before = son_figure(b, base) + keep = plant_son(out, NAMED, og) + try: + with open(os.path.join(out, "runs", son, "results.json")) as fh: + tags = (json.load(fh).get("veil") or {}).get("findings") or {} + if tags.get("pl-q") != "pl-x" or tags.get("pl-r") != "pl-x" or row_tag(out, "pl-r"): + return [f"the server's tags {tags}, og's tag on pl-r {row_tag(out, 'pl-r')}: the planting tests nothing"] + path = os.path.join(out, "runs", son, "map.json") + doc = read_map(path) + f = doc["per"][son]["findings"] + if "pl-x" not in f["id"] or "pl-x" not in doc["dict"]["veil"]: + return ["og did not place or tag pl-x: the planting tests nothing"] + i, n = f["id"].index("pl-x"), len(f["id"]) + for k in f: + if isinstance(f[k], list) and len(f[k]) == n: + f[k].append(f[k][i]) + for k, v in GHOST.items(): + f[k][-1] = v + f["veil"][-1] = doc["dict"]["veil"].index("pl-x") + write_map(path, doc) + c, fig = son_figure(b, base) + bad = [] + ids = [m["id"] for m in c["marks"]] + shown = [x for x in ("pl-x", "pl-q", "pl-r", "pl-ghost") if x in ids] + shown += [w for w in ("PLXGENE", "Ghost", "GHOSTGENE") if w in c["text"] or w in c["tip"] or w in c["hash"]] + if shown: + bad.append(f"closed: the map shows {shown}") + if fig != before: + bad.append(f"closed: the figure differs from the page without them " + f"({[k for k in ('html', 'canvas') if fig.get(k) != before.get(k)]})") + b.eval("document.querySelector('#f-pl-x .veil button').click(); true") + c = map_now(b) + ids = [m["id"] for m in c["marks"]] + if not {"pl-x", "pl-q", "pl-r"} <= set(ids): + bad.append(f"opened by its card: marks {ids}") + return bad + finally: + unplant(keep) + + +def chip_problems(b, base, out, og): + """The son's map before any click is the same, its chips included, whether his planted closed result has a + place on the map (chr13) or none (no viewer, a gene not in GENCODE): the chip counts the results the page + keeps closed, never only the placed ones.""" + got, bad = {}, [] + for name, fid, viewer in (("no place", "pl-nowhere", ""), ("a place", "pl-somewhere", "chr13:32340000")): + keep = plant_son(out, [act(fid, "PL" + fid[3:].upper(), viewer, "Planted closed result")], og) + try: + placed = fid in read_map(os.path.join(out, "runs", OTHERS[1], "map.json"))["per"][OTHERS[1]]["findings"]["id"] + if placed != bool(viewer): + bad.append(f"{name}: og placed {fid} {placed}: the planting tests nothing") + c, fig = son_figure(b, base) + got[name] = (c["chips"], fig) + finally: + unplant(keep) + if got["no place"] != got["a place"]: + bad.append(f"the chips {got['no place'][0]} with no place, {got['a place'][0]} with one" + + ("" if got["no place"][1] == got["a place"][1] else ", and the figure differs")) + return bad + + +def runs_sentence(b, base): + """Whether DEMO-001's planted runs sentence on chr1 is in the margin at the first frame of the replay and + at the last: (first, last, the level and chromosome at the first frame).""" + map_page(b, base, S, "#chr1") + seen = [] + for to in ("0", "s.max"): + b.eval("var s = document.getElementById('replay-slider'); s.value = %s; s.dispatchEvent(new Event('input')); true" % to) + c = map_now(b) + seen.append(("PLANTEDRUNS" in c["text"], c["level"] + " " + c["chrom"])) + return seen[0][0], seen[1][0], seen[0][1] + + +def runs_sentence_problems(b, base, out): + """A replay before the runs-of-homozygosity step ended has no sentence about them on an open chromosome; + at its end it has it.""" + path = os.path.join(out, "runs", S, "map.json") + with open(path, "rb") as fh: + keep = fh.read() + try: + doc = read_map(path) + doc.setdefault("text", {}).setdefault("chrom", {})["chr1"] = [{"t": "PLANTEDRUNS sentence.", "b": False}] + write_map(path, doc) + first, last, where = runs_sentence(b, base) + if where != "chromosome chr1": + return [f"the first frame is at {where}: the check tests nothing"] + return (["the first frame says the runs sentence"] if first else []) + ([] if last else ["the last frame lacks it"]) + finally: + with open(path, "wb") as fh: + fh.write(keep) + + +# A second figure on DEMO-001's page with a veil that keeps nothing closed: drawn from map.json, then reloaded +# from a file that is not there. +SECOND_JS = r"""(function () { + var o = { url: 'map.json', veil: function () { return 'open'; }, now: function () { return true; }, + known: function () { return true; }, stepDone: function () { return true; }, open: function () {}, + close: function () {}, fallback: function () { return []; } }; + var g = window.PGPMap.create(o); + g.node.id = 'gm-second'; + document.body.appendChild(g.node); + window.pgpSecond = { g: g, o: o }; + g.refresh('k1'); + return true; +}())""" +SECOND_FACTS = r"""JSON.stringify((function () { + var f = document.getElementById('gm-second'); + return { state: f.dataset.pgpMap, marks: f.querySelectorAll('.gm-marks a').length, + narr: f.querySelector('.gm-narration').textContent, layers: f.querySelector('.gm-layers').textContent, + tip: f.querySelector('.gm-tip').hidden ? '' : f.querySelector('.gm-tip').textContent }; +}()))""" + + +def failed_reload_problems(b, base): + """A figure whose reload fails draws nothing more of the document it had: no mark link, no margin sentence, + no layer line, no tooltip.""" + map_page(b, base, S) + b.eval(SECOND_JS) + if not b.wait("document.getElementById('gm-second').dataset.pgpMap === 'drawn'"): + return ["the second figure never drew"] + time.sleep(0.6) + drawn = json.loads(b.eval(SECOND_FACTS)) + if not drawn["marks"] or not drawn["narr"]: + return [f"the second figure drew no mark or sentence: the check tests nothing ({drawn})"] + b.eval("pgpSecond.o.url = 'no-such-map.json'; pgpSecond.g.refresh('k2'); true") + if not b.wait("document.getElementById('gm-second').dataset.pgpMap === 'failed'"): + return ["the second figure's reload did not fail"] + time.sleep(1.0) + c = json.loads(b.eval(SECOND_FACTS)) + return [f"after the failed reload: {c}"] if c["marks"] or c["narr"] or c["layers"] or c["tip"] else [] + + def replay_apoe_problems(b, base): """DEMO-001 at the first frame of its replay, before the traits step: the map has no APOE chip, no switch to open it and no APOE mark, as its card has no fold yet.""" @@ -1784,6 +1942,33 @@ def map_checks(b, base, out): finally: with open(mjs, "w") as fh: fh.write(keep_mjs) + bad = named_problems(b, base, out, og) + check("map: a finding the server closes with a closed result, and a tagged map row the page has no finding " + "for, stay off the map until the result is opened", not bad, bad) + now = "&& (!opts.now || opts.now(r.id));" + try: + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(now, "&& true;")) + control("map.js whose rows ignore what the page shows now", keep_mjs.count(now) != 1 or + not named_problems(b, base, out, og)) + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(known, "true || ")) + control("map.js that takes every row for a finding of the page", keep_mjs.count(known) != 1 or + not named_problems(b, base, out, og)) + finally: + with open(mjs, "w") as fh: + fh.write(keep_mjs) + bad = chip_problems(b, base, out, og) + check("map: the closed-results chip counts the results the page keeps closed, with a place on the map or " + "without", not bad, bad) + try: + with open(js, "w") as fh: + fh.write(keep_js.replace("closed: mapClosed, ", "")) + control("person.js that does not tell the map which results it keeps closed", + keep_js.count("closed: mapClosed, ") != 1 or not chip_problems(b, base, out, og)) + finally: + with open(js, "w") as fh: + fh.write(keep_js) bad = replay_apoe_problems(b, base) check("map: before the traits step a replay has no APOE chip, switch or mark", not bad, bad) gated = "if (M.gated[id] && opts.now && !opts.now(id)) { return; }" @@ -1801,6 +1986,32 @@ def map_checks(b, base, out): fh.write(keep_mjs) bad = missing_layer_problems(b, base, out) check("map: a missing layer says why in words, never a blank", not bad, bad) + bad = runs_sentence_problems(b, base, out) + check("map: a replay says an open chromosome's runs sentence only once the runs' step ended", not bad, bad) + waits = "&& rohLayer() === M.layers.roh" + try: + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(waits, "")) + control("map.js whose runs sentence does not wait for its step", keep_mjs.count(waits) != 1 or + not runs_sentence_problems(b, base, out)) + finally: + with open(mjs, "w") as fh: + fh.write(keep_mjs) + bad = failed_reload_problems(b, base) + check("map: after a failed reload the figure draws nothing more of its old document", not bad, bad) + undone = [("if (!M || failure) { return; }", "if (!M) { return; }", 2), ("if (!raf && !failure) {", "if (!raf) {", 1), + ("window.cancelAnimationFrame(raf);\n raf = 0;\n window.clearTimeout(settleT);\n", "", 1)] + try: + planted_mjs = keep_mjs + for a, z, _ in undone: + planted_mjs = planted_mjs.replace(a, z) + with open(mjs, "w") as fh: + fh.write(planted_mjs) + control("map.js that keeps drawing after a failed reload", + any(keep_mjs.count(a) != n for a, _, n in undone) or not failed_reload_problems(b, base)) + finally: + with open(mjs, "w") as fh: + fh.write(keep_mjs) bad = fallback_problems(b, base, out) check("map: a browser with no DecompressionStream gets the sentence and a findings table through the veil", not bad, bad) b.call("Emulation.setDeviceMetricsOverride", {"width": 390, "height": 844, "deviceScaleFactor": 2, "mobile": False}, b.session) From a6e3f1527910b6d6e6f51f588b8a6502f1c2427e Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:22:18 +0100 Subject: [PATCH 19/27] fix(live): open the map chip's closed results in the page's order, one per click The chip's Open opened the closed results with a place on the map first, so after one click the order showed whether a result still closed has a place. It now opens the next closed result in the page's own order. A result with no place gets an "Opened in its section" chip with a link to its card and a button to close it again. The keyboard follows the chip's buttons. The map checks now assert that each Open opens exactly one result and that the count falls by one, that the order is the same whichever result has a place, and where focus goes, each with its planted control. The map's ready wait no longer runs to its timeout on a browser without DecompressionStream, and the state check reuses its first page load. --- assets/live/map.js | 45 +++++-- assets/live/person.js | 4 +- docs/live-site.md | 2 +- rust/crates/og-map/README.md | 2 +- tests/test_live_demo.py | 220 ++++++++++++++++++++++++++++++----- 5 files changed, 230 insertions(+), 43 deletions(-) diff --git a/assets/live/map.js b/assets/live/map.js index a9448a17e..f19b2d39d 100644 --- a/assets/live/map.js +++ b/assets/live/map.js @@ -154,6 +154,8 @@ var CW = 0, CH = 0, DPR = 1, phone = false; var cam = { ci: 0, c: 0, ls: 0, k: 0 }, tgt = { ci: 0, c: 0, ls: 0, k: 0 }; var hover = { row: -1, hit: null }, hits = [], raf = 0, settleT = 0, hashT = 0, confirming = null, touched = false; + // The results with no place on the map that its chip opened: each gets an "Opened in its section" chip while open. + var inSection = []; var cache = null, cacheKey = ''; var hatch = null; @@ -599,15 +601,14 @@ return closedAny() ? 'No open finding with a place on this chromosome.' : 'No finding with a place on this chromosome.'; } function closedAny() { return closedActs().length > 0 || M.closable.some(function (id) { return stateOf(id) === 'closed'; }); } - // The results to act on this page keeps closed, in the order Open opens them: the ones with a place on the - // map first, in map order, then the rest of the page's closed results, which have no place to draw. Its - // length is what the chip counts, so the count never tells whether a closed result has a place. + // The results to act on this page keeps closed, in the order Open opens them: the page's own order (the + // order of its closed cards), with a place on the map or without. Its length is what the chip counts, so + // neither the count nor the order tells whether a closed result has a place. A page that passes no list + // closes only the map's own rows, in map order. function closedActs() { var page = opts.closed ? opts.closed() : null; - var placed = M.closable.filter(function (id) { - return !M.gated[id] && stateOf(id) === 'closed' && (!page || page.indexOf(id) >= 0); - }); - return page ? placed.concat(page.filter(function (id) { return placed.indexOf(id) < 0; })) : placed; + if (page) { return page.filter(function (id) { return !M.gated[id]; }); } + return M.closable.filter(function (id) { return !M.gated[id] && stateOf(id) === 'closed'; }); } function layerWords(lay) { if (lay.state === 'missing') { return 'Not on the map: ' + (lay.note || ('step ' + lay.step + ' wrote no ' + (lay.source || 'file') + '.')); } @@ -888,30 +889,48 @@ if (acts.length) { var c = el('div', { 'class': 'gm-chip closed', 'data-closed': 'act' }); add(c, add(el('span', {}, acts.length === 1 ? '1 result closed' : acts.length + ' results closed'), why())); - add(c, confirming === 'act' ? confirmBox(acts[0], false) : button('Open', function () { confirming = 'act'; drawChips(); })); + add(c, confirming === 'act' ? confirmBox(acts[0], false) : button('Open', function () { confirming = 'act'; drawChips(); keyTo('.gm-yes'); })); add(chips, c); } gates.forEach(function (id) { var g = M.gated[id], name = g.gene || 'This result'; var c = el('div', { 'class': 'gm-chip closed', 'data-closed': 'gate' }); add(c, add(el('span', {}, name + ' closed'), why())); - add(c, confirming === id ? confirmBox(id, true) : button('Open ' + name, function () { confirming = id; drawChips(); })); + add(c, confirming === id ? confirmBox(id, true) : button('Open ' + name, function () { confirming = id; drawChips(); keyTo('.gm-yes'); })); add(chips, c); }); opened.forEach(function (id) { var r = M.findings.filter(function (x) { return x.id === id; })[0]; var c = el('div', { 'class': 'gm-chip opened', 'data-opened': '1' }); add(c, el('span', {}, 'Opened: ' + (r ? r.title : 'a result to act on'))); - add(c, button('Close it again', function () { confirming = null; opts.close(id); })); + add(c, button('Close it again', function () { confirming = null; opts.close(id); keyTo(); })); + add(chips, c); + }); + // A result the chip opened that has no place: named only once it is open, with a link to its card. + inSection = inSection.filter(function (id) { return stateOf(id) !== 'closed' && opts.title && opts.title(id); }); + inSection.forEach(function (id) { + var c = el('div', { 'class': 'gm-chip opened', 'data-opened': 'section' }); + add(c, add(el('span', {}, 'Opened in its section: '), el('a', { href: '#f/' + id }, opts.title(id)))); + add(c, button('Close it again', function () { confirming = null; opts.close(id); keyTo(); })); add(chips, c); }); } + // Where the keyboard goes once a chip's button rebuilt the chips: the chips' first button, else the map. + function keyTo(sel) { + var b = chips.querySelector(sel || 'button'); + (b || canvas).focus({ preventScroll: true }); + } function confirmBox(id, gated) { var box = el('span', { 'class': 'gm-confirm' }); add(box, el('span', {}, gated ? 'It is kept behind its warning on every page. Open it here and in its section?' : 'A result to act on in adult life is usually not looked at before the child can choose. Open it here and in its section?')); - add(box, button('Show the result', function () { confirming = null; opts.open(id); }, 'gm-yes')); - add(box, button('Keep it closed', function () { confirming = null; drawChips(); }, 'gm-no')); + add(box, button('Show the result', function () { + confirming = null; + if (!gated && M.closable.indexOf(id) < 0 && inSection.indexOf(id) < 0) { inSection.push(id); } + opts.open(id); // the page draws again, and the map's chips with it (refresh) + keyTo(); + }, 'gm-yes')); + add(box, button('Keep it closed', function () { confirming = null; drawChips(); keyTo(); }, 'gm-no')); return box; } function button(text, fn, cls) { @@ -1047,6 +1066,8 @@ function fail(why, words) { failure = why; // The frame and the margin the last refresh scheduled would draw the old document again: drop them. + // These lines and the failure guards of draw(), describe() and schedule() cover for each other, so no one + // of them can go red alone: the failed-reload check undoes them together. window.cancelAnimationFrame(raf); raf = 0; window.clearTimeout(settleT); diff --git a/assets/live/person.js b/assets/live/person.js index f00936988..82ad6b21a 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -565,6 +565,8 @@ function mapClosed() { return findingsNow().filter(function (f) { return f.level === 'act' && veiled(f); }).map(function (f) { return f.id; }); } + // The title of a result once the page shows it, for the map's "Opened in its section" chip; never a closed one's. + function mapTitle(id) { var f = findingById(id); return f && !veiled(f) ? f.title : ''; } // Whether a row of the map is a finding of these results: then the page's own veil alone decides it (map.js). function mapKnown(id) { return !!findingById(id); } // Whether a step of the run has ended at this moment (a replay before it has not): the map's layers follow it. @@ -896,7 +898,7 @@ // reader left it. It asks this page's veil for each closed row, and opens or closes a result through it. if (window.PGPMap) { if (!genomeMap) { - genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, known: mapKnown, closed: mapClosed, stepDone: stepDone, open: function (id) { setShown(id, true); }, + genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, known: mapKnown, closed: mapClosed, title: mapTitle, stepDone: stepDone, open: function (id) { setShown(id, true); }, close: function (id) { setShown(id, false); }, fallback: mapFallback }); } add(slot, genomeMap.node); diff --git a/docs/live-site.md b/docs/live-site.md index 1fd6d8c42..80f70e55c 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 its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens the closed results with a place first, in map order; after them it opens the next closed result in its section, and the map then draws nothing new, as that result has no place on it. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. +A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows and lines, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens one result per click, the next closed result in the page's order (the order of its closed cards), so the order does not tell either whether a closed result has a place. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A result with no place on the map opens in its section only: the map draws nothing new, and a chip reads "Opened in its section:" with the result's title, a link to its card and a button to close it again. That chip names only a result that is open, and it goes when the result closes. After Open the keyboard moves to "Show the result", and after "Show the result" or "Keep it closed" to the chips' first button, or to the map when no chip is left. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. A stopped run's page says so under the name and in the summary line, with the day it was last heard from, and warns that the report may be incomplete; a section still waiting for its step says the run stopped before that step ended. diff --git a/rust/crates/og-map/README.md b/rust/crates/og-map/README.md index eee8c3f5d..b2906b391 100644 --- a/rust/crates/og-map/README.md +++ b/rust/crates/og-map/README.md @@ -77,7 +77,7 @@ One gzip member with mtime 0 and no file name, holding JSON written as Python's - **The veil.** The document is the same for every reader. Whether a page is closed is the page's own rule; the document says only what a closed result could reveal. Closable findings are those of level `act` and every gated one (APOE closes on every page, an adult's too). Each closable finding with a place veils its span, and `dict.veil` lists their ids: gated ones first, then the act ones in the page's order. A row of a layer that can be a finding's own source (structural variants, mosaic calls and ClinVar hits, as their layers arrive), and every findings row, that overlaps a veiled span carries the index of the first such finding in `dict.veil` in a `veil` column, else -1. A row holds one tag only. Gated findings come first, so a row over both APOE and an act finding carries APOE, which is closed on every page; a row over two act findings carries the first, and both close under the same rule. The page decides its own findings with its own veil, and the tag binds only the rows that are not one of the page's findings (APOE, or a row the page's results do not have yet): the page hides such a row while its result is closed. Counts, totals and density bins cover untagged rows only, so no number counts a closed row. Runs of homozygosity and bands are never a finding's source and carry no tag. - `roh`: the `RG` rows of step 11's `bcftools roh` text (`{run}/vcf/{run}_roh.txt`, else `{run}/roh/{run}_roh.txt`). bcftools writes 1-based inclusive positions, so `s = start - 1` and `e = end`. `ST` lines are skipped; rows on chrM and other contigs count in `totals.all` only. `markers` and `q` are bcftools' columns 7 and 8. `art` indexes `dict.art`: `one X` on a male's chrX, `one Y` on a male's chrY, else `centromere` when more than half the run lies in masked spans. An unknown sex sets neither flag. `long` is 1 for a run of 5 Mb or more with no `art`. - `roh_bins`: bases covered by any run per 1 Mb bin, for each chromosome with a run. -- `text`: the sentences the page shows, in pieces; `b` marks bold words. `text.findings` is the count of findings on the map in three forms (`none`, `one`, `many`): the page counts the findings rows whose `veil` is -1 plus the tagged rows whose result is open, picks the form and puts the number in place of `{n}`. +- `text`: the sentences the page shows, in pieces; `b` marks bold words. `text.findings` is the count of findings on the map in three forms (`none`, `one`, `many`): the page counts the rows it draws: each of its own findings that its veil shows at this moment, whatever the row's tag, and each other row whose tag is -1 or names a result that is open; it picks the form and puts the number in place of `{n}`. ## Tests diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 11811cf33..4978049fb 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -1255,7 +1255,7 @@ def write_map(path, doc): hash: location.hash }; }()))""" MAP_READY = ("document.documentElement.dataset.pgpResults && (function () { var f = document.getElementById('genome-map'); " - "return f && /^(drawn|failed|nodecompress)$/.test(f.dataset.pgpMap || '') && f.dataset.level !== undefined " + "return f && /^(drawn|failed|nodecompress)$/.test(f.dataset.pgpMap || '') " "&& (f.dataset.pgpMap !== 'drawn' || !!f.dataset.level); }())") @@ -1275,6 +1275,61 @@ def map_now(b): return json.loads(b.eval(MAP_JS)) +# The page's closed results to act on and the map's chips, read at once (a chip's button rebuilds the chips at +# once: nothing to wait for): the closed act cards in page order, the act chip's count, whether its confirm step +# shows, where the keyboard is (first: the chips' first button; canvas: the map), and the opened chips. +CHIP_JS = r"""JSON.stringify((function () { + var ids = []; + Array.prototype.forEach.call(document.querySelectorAll('.finding[data-level=act]'), function (a) { + if (a.querySelector('.veil') && ids.indexOf(a.dataset.finding) < 0) { ids.push(a.dataset.finding); } }); + var c = document.querySelector('.gm-chip[data-closed=act]'), f = document.activeElement; + var first = document.querySelector('.gm-chips button'); + return { closed: ids, buttons: !!first, chip: c ? c.firstChild.textContent : '', confirm: !!(c && c.querySelector('.gm-confirm')), + focus: f && f.classList.contains('gm-yes') ? 'gm-yes' : f === first ? 'first' : f === document.querySelector('.gm-canvas') ? 'canvas' + : (f ? f.tagName + '.' + f.className : ''), + opened: Array.prototype.map.call(document.querySelectorAll('.gm-chip.opened'), function (o) { var a = o.querySelector('a'); + return { kind: o.dataset.opened, text: o.textContent, href: a ? a.getAttribute('href') : '' }; }) }; +}()))""" + + +def chips_now(b): + time.sleep(0.05) # a chip's button draws the page and the chips again before its click returns + return json.loads(b.eval(CHIP_JS)) + + +def closed_words(n): + return "" if n == 0 else "1 result closed" if n == 1 else f"{n} results closed" + + +def chip_open(b): + """The act chip's Open, then its Show the result, as a keyboard reader does it. Returns (the id it opened, + problems): one Open opens exactly one of the page's closed results, the count falls by one, the keyboard + goes to Show the result and then to the chips' first button (the map when no chip is left), and an + "Opened in its section" chip never names a result that is still closed.""" + before = chips_now(b) + b.eval("var o = document.querySelector('.gm-chip[data-closed=act] button'); o.focus(); o.click(); true") + mid = chips_now(b) + bad = [] + if not mid["confirm"] or mid["closed"] != before["closed"]: + bad.append(f"Open: confirm step {mid['confirm']}, closed {before['closed']} -> {mid['closed']}") + if mid["focus"] != "gm-yes": + bad.append(f"Open: the keyboard is on {mid['focus']!r}, not Show the result") + b.eval("document.querySelector('.gm-chip[data-closed=act] .gm-yes').click(); true") + after = chips_now(b) + gone = [x for x in before["closed"] if x not in after["closed"]] + n = len(before["closed"]) - 1 + if len(gone) != 1 or len(after["closed"]) != n: + bad.append(f"Show the result opened {gone}, not one: closed {before['closed']} -> {after['closed']}") + if not after["chip"].startswith(closed_words(n)) or bool(after["chip"]) != (n > 0): + bad.append(f"Show the result: the chip reads {after['chip']!r}, want {closed_words(n)!r}") + if after["focus"] != ("first" if after["buttons"] else "canvas"): + bad.append(f"Show the result: the keyboard is on {after['focus']!r}") + named = [o["href"] for o in after["opened"] if o["kind"] == "section" and o["href"][3:] in after["closed"]] + if named: + bad.append(f"an Opened in its section chip names a closed result: {named}") + return (gone[0] if len(gone) == 1 else None), bad + + def parity_problems(b, base, rows): """The karyogram's marks the map does not place with the same level and chromosome within 0.01 Mb.""" bad = [] @@ -1323,6 +1378,14 @@ def state_problems(b, base): g = map_page(b, base, S) if not (g["level"] == "genome" and "all 24 chromosomes" in g["title"] and g["view"] == "genome"): bad.append(f"genome: {g['level']} {g['title']!r} {g['view']}") + click_canvas(b, 200, 9 + 2 * 22 + 11) + c = map_now(b) + if not (c["level"] == "chromosome" and c["chrom"] == "chr3" and c["hash"] == "#chr3"): + bad.append(f"a click on chromosome 3's row: {c['level']} {c['chrom']} {c['hash']}") + b.eval("document.querySelector('.gm-canvas').dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); true") + c = map_now(b) + if c["level"] != "genome" or c["hash"] != "#genome": + bad.append(f"Esc: {c['level']} {c['hash']}") for link, chrom, view in (("#chr16", "chr16", "chr16"), ("#chr16:10.0-40.0M", "chr16", "chr16:10.0-40.0M"), ("#view=chromosome&chr=11", "chr11", "chr11"), ("#chr2:20000001-60000000", "chr2", "chr2:20.0-60.0M")): c = map_page(b, base, S, link) @@ -1330,32 +1393,24 @@ def state_problems(b, base): bad.append(f"{link}: {c['level']} {c['chrom']} {c['view']}") elif any(m["chrom"] != chrom for m in c["marks"]): bad.append(f"{link}: marks of another chromosome listed: {c['marks']}") - c = map_page(b, base, S, "#chr16") - b.eval("location.hash = '#genome'; true") + b.eval("location.hash = '#genome'; true") # after the last link's place c = map_now(b) if c["level"] != "genome": - bad.append(f"#genome after #chr16: {c['level']}") + bad.append(f"#genome after a place: {c['level']}") b.eval("location.hash = '#chrQ'; true") c = map_now(b) if "Could not read the place" not in c["toast"] or c["level"] != "genome": bad.append(f"#chrQ: toast {c['toast']!r}, level {c['level']}") - g = map_page(b, base, S) - click_canvas(b, 200, 9 + 2 * 22 + 11) - c = map_now(b) - if not (c["level"] == "chromosome" and c["chrom"] == "chr3" and c["hash"] == "#chr3"): - bad.append(f"a click on chromosome 3's row: {c['level']} {c['chrom']} {c['hash']}") - b.eval("document.querySelector('.gm-canvas').dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); true") - c = map_now(b) - if c["level"] != "genome" or c["hash"] != "#genome": - bad.append(f"Esc: {c['level']} {c['hash']}") return bad def map_veil_problems(b, base, out): """On the son's closed page: his closed result to act on (the planted deletion) is in no mark, tooltip, - chip, sentence, link or address of the map until opened; the chip's Open (after its confirm) and the card's - "Show the result" each bring back exactly its mark, on the map and in the card; Close it again and a reload - close it. On DEMO-001, an adult's page, APOE stays closed until opened, from the chip or its card's fold.""" + chip, sentence, link or address of the map until opened; the chip's Open (after its confirm) opens one + result per click in the page's order (chip_open), so his result with no place opens first, in its section; + then Open and the card's "Show the result" each bring back exactly the deletion's mark, on the map and in + the card; Close it again and a reload close it. On DEMO-001, an adult's page, APOE stays closed until + opened, from the chip or its card's fold.""" bad = [] son = OTHERS[1] doc = read_map(os.path.join(out, "runs", son, "map.json")) @@ -1368,8 +1423,8 @@ def map_veil_problems(b, base, out): words = [gene, f"{f['chrom'][i]}:{f['s'][i] + 1}", f"{f['s'][i] + 1:,}", fid] x, y = genome_xy(b, doc, f["chrom"][i], (f["s"][i] + f["e"][i]) / 2) - def leaks(c): - return [w for w in words if w in c["text"] or w in c["tip"] or w in c["hash"]] + \ + def leaks(c, but=""): + return [w for w in words if w not in but and (w in c["text"] or w in c["tip"] or w in c["hash"])] + \ [m["id"] for m in c["marks"] if m["id"] == fid] c = map_page(b, base, son) click_canvas(b, x, y, move_only=True) @@ -1384,11 +1439,23 @@ def leaks(c): label = "1 result closed" if want == 1 else f"{want} results closed" if not want or not any(t.startswith(label + " ") for t in c["chips"]): bad.append(f"closed: no chip counting the page's {want} closed results: {c['chips']}") - b.eval("document.querySelector('.gm-chip[data-closed=act] button').click(); true") - c = map_now(b) - if not b.eval("!!document.querySelector('.gm-chip[data-closed=act] .gm-confirm')") or leaks(c): - bad.append("Open: no confirm step, or it opened at once") - b.eval("document.querySelector('.gm-chip[data-closed=act] .gm-yes').click(); true") + # Open opens the page's closed results one per click, in the page's order, so the son's own result with no + # place (his first closed card) opens before the deletion: the map then names it only in its section chip. + order = chips_now(b)["closed"] + for k in range(want): + got, more = chip_open(b) + if more: + return bad + [f"Open {k + 1}: {x}" for x in more] + if got != order[k]: + return bad + [f"Open {k + 1} opened {got}, not the page's next closed result {order[k]} ({order})"] + if got == fid: + break + section = [o for o in chips_now(b)["opened"] if o["kind"] == "section"] + if [o["href"] for o in section] != [f"#f/{got}"] or "Opened in its section: " not in section[0]["text"]: + bad.append(f"Open {k + 1} opened {got}, which has no place: section chips {section}") + c = map_now(b) + if leaks(c, " ".join(o["text"] for o in section)): # the opened result names its own gene + bad.append(f"Open {k + 1} ({got}): the map shows {leaks(c)}") c = map_now(b) card = b.eval("!!document.querySelector('#f-%s h3') && !document.querySelector('#f-%s .veil')" % (fid, fid)) if [m["id"] for m in c["marks"] if m["id"] == fid] != [fid] or not card: @@ -1401,7 +1468,12 @@ def leaks(c): c = map_now(b) if gene not in c["tip"]: bad.append(f"Open: its tooltip at its place does not name it: {c['tip']!r}") - b.eval("document.querySelector('.gm-chip.opened button').click(); true") + # The results opened before it in their section close again from their chips, and those chips go. + b.eval("Array.prototype.forEach.call(document.querySelectorAll('.gm-chip.opened[data-opened=section]'), " + "function () { document.querySelector('.gm-chip.opened[data-opened=section] button').click(); }); true") + if [o for o in chips_now(b)["opened"] if o["kind"] == "section"] or chips_now(b)["closed"] != order[:k]: + bad.append(f"Close it again on the section chips: chips {chips_now(b)['opened']}, closed {chips_now(b)['closed']}") + b.eval("document.querySelector('.gm-chip.opened[data-opened=\\'1\\'] button').click(); true") c = map_now(b) card = b.eval("!!document.querySelector('#f-%s .veil')" % fid) if leaks(c) or not card: @@ -1701,6 +1773,61 @@ def chip_problems(b, base, out, og): return bad +# Two planted closed results, one with a place on the map and one without, in both orders of the page. +OPEN_ORDER = (("unplaced pl-a, placed pl-b", [act("pl-a", "PLAGENE", "", "Planted a result"), + act("pl-b", "PLBGENE", "chr13:32340000", "Planted b result")]), + ("placed pl-a, unplaced pl-b", [act("pl-a", "PLAGENE", "chr13:32340000", "Planted a result"), + act("pl-b", "PLBGENE", "", "Planted b result")])) + + +def open_order_problems(b, base, out, og): + """For each planting of OPEN_ORDER, the chip's Open clicked until nothing is closed (chip_open checks each + click): the ids it opens, click by click, are the same in both, so the order never tells which closed result + has a place. A result with no place opens with an "Opened in its section" chip that links its card; its + Close it again closes it on the page and takes the chip away. Before that, Open then Keep it closed opens + nothing and gives the keyboard back to the chips.""" + seqs, bad = {}, [] + for k, (what, extra) in enumerate(OPEN_ORDER): + keep = plant_son(out, extra, og) + try: + placed = read_map(os.path.join(out, "runs", OTHERS[1], "map.json"))["per"][OTHERS[1]]["findings"]["id"] + if [x["id"] in placed for x in extra] != [bool(x["viewer"]) for x in extra]: + return [f"{what}: og placed {[x['id'] for x in extra if x['id'] in placed]}: the planting tests nothing"] + map_page(b, base, OTHERS[1]) + first = chips_now(b) + if k == 0: + b.eval("var o = document.querySelector('.gm-chip[data-closed=act] button'); o.focus(); o.click(); true") + b.eval("document.querySelector('.gm-chip[data-closed=act] .gm-no').click(); true") + kept = chips_now(b) + if kept["closed"] != first["closed"] or kept["confirm"] or kept["focus"] != "first": + return [f"{what}: Keep it closed: closed {kept['closed']}, confirm {kept['confirm']}, " + f"keyboard on {kept['focus']!r}"] + seq = [] + for _ in first["closed"]: + got, more = chip_open(b) + if more: + return bad + [f"{what}, Open {len(seq) + 1}: {x}" for x in more] + seq.append(got) + opened = chips_now(b)["opened"] + section = [o["href"] for o in opened if o["kind"] == "section"] + if (got in placed) == (f"#f/{got}" in section): + return [f"{what}: {got} (placed {got in placed}) opened with section chips {section}"] + seqs[what] = seq + if k == 0: + gone = [x["id"] for x in extra if not x["viewer"]][0] + b.eval("var o = document.querySelector('.gm-chip.opened[data-opened=section] a[href=\"#f/%s\"]'); " + "o.parentNode.parentNode.querySelector('button').click(); true" % gone) + after = chips_now(b) + if after["closed"] != [gone] or any(o["kind"] == "section" and gone in o["href"] for o in after["opened"]): + bad.append(f"{what}: Close it again on {gone}'s section chip: closed {after['closed']}, " + f"opened chips {after['opened']}") + finally: + unplant(keep) + if len(set(map(tuple, seqs.values()))) != 1: + bad.append(f"the order Open opens them tells which has a place: {seqs}") + return bad + + def runs_sentence(b, base): """Whether DEMO-001's planted runs sentence on chr1 is in the margin at the first frame of the replay and at the last: (first, last, the level and chromosome at the first frame).""" @@ -1900,21 +2027,36 @@ def map_checks(b, base, out): keep_js = fh.read() bad = map_veil_problems(b, base, out) check("map: the son's closed result is in no mark, tooltip, chip, sentence, link or address until opened; Open " - "(after its confirm) and the card's Show the result each bring back exactly its mark there and in the card; " + "(after its confirm) opens one result per click, in the page's order, and the count falls by one; Open and " + "the card's Show the result each bring back exactly its mark there and in the card; " "Close it again and a reload close it; APOE stays closed on an adult's page until opened, from the chip " "or its card's fold", not bad, bad) + mjs = os.path.join(out, "assets", "map.js") + with open(mjs) as fh: + keep_mjs = fh.read() + one = "opts.open(id); // " + every = "f.level === 'act' && veiled(f); })" try: with open(js, "w") as fh: fh.write(keep_js.replace("function mapVeil(id, gated) {", "function mapVeil(id, gated) { return 'open';")) control("person.js whose map veil keeps nothing closed", not map_veil_problems(b, base, out)) + with open(js, "w") as fh: + fh.write(keep_js.replace(every, "f.level === 'act'; })")) + control("person.js whose map chip counts every result to act on, closed or open", keep_js.count(every) != 1 or + not map_veil_problems(b, base, out)) + with open(js, "w") as fh: + fh.write(keep_js) + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(one, "(gated ? [id] : closedActs()).forEach(function (x) { opts.open(x); }); // ")) + control("map.js whose Open opens every closed result at once", keep_mjs.count(one) != 1 or + not map_veil_problems(b, base, out)) finally: with open(js, "w") as fh: fh.write(keep_js) + with open(mjs, "w") as fh: + fh.write(keep_mjs) bad = overlap_problems(b, base, out) check("map: a second closed result to act on over the first stays closed when the first is opened", not bad, bad) - mjs = os.path.join(out, "assets", "map.js") - with open(mjs) as fh: - keep_mjs = fh.read() own = "var own = M.closable.indexOf(r.id) < 0 || stateOf(r.id) !== 'closed';" try: with open(mjs, "w") as fh: @@ -1969,6 +2111,28 @@ def map_checks(b, base, out): finally: with open(js, "w") as fh: fh.write(keep_js) + bad = open_order_problems(b, base, out, og) + check("map: the chip's Open opens one closed result per click in the page's order, whether it has a place or " + "not; one with no place gets an Opened in its section chip until closed again; the keyboard follows", + not bad, bad) + concat = "if (page) { return page.filter(function (id) { return !M.gated[id]; }); }" + placed_first = ("if (page) { var placed = M.closable.filter(function (id) { return !M.gated[id] && stateOf(id) === " + "'closed' && page.indexOf(id) >= 0; }); return placed.concat(page.filter(function (id) { return " + "placed.indexOf(id) < 0; })); }") + alive = "stateOf(id) !== 'closed' && opts.title && opts.title(id);" + pushed = "inSection.push(id);" + focus = "(b || canvas).focus({ preventScroll: true });" + try: + for desc, a, z in (("map.js whose Open opens the closed results with a place first", concat, placed_first), + ("map.js whose section chip stays for a closed result", alive, "true;"), + ("map.js with no section chip for a result with no place", pushed, ""), + ("map.js that leaves the keyboard where the chips were rebuilt", focus, "void b;")): + with open(mjs, "w") as fh: + fh.write(keep_mjs.replace(a, z)) + control(desc, keep_mjs.count(a) != 1 or not open_order_problems(b, base, out, og)) + finally: + with open(mjs, "w") as fh: + fh.write(keep_mjs) bad = replay_apoe_problems(b, base) check("map: before the traits step a replay has no APOE chip, switch or mark", not bad, bad) gated = "if (M.gated[id] && opts.now && !opts.now(id)) { return; }" From e5306541a8c1cf81bcc1efa6975a78901e152736 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:28:58 +0100 Subject: [PATCH 20/27] ci(guard): give the unit-test shards 15 minutes so the browser tests stop racing the limit --- .github/workflows/guard.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index 3c9248867..1d59f9971 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -156,11 +156,12 @@ jobs: # Three shards: one job ran every test file in about 9.5 of its 10 minutes, # so each new browser test pushed it over. File i runs in shard i % 3, so - # every file runs in exactly one shard. + # every file runs in exactly one shard. The limit is 15 minutes: the shard + # that holds the browser tests of the live page already takes about 9. unit-tests: needs: og runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: From 1a23510c3caff68a3051481dfc38402dda695b8b Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:16:27 +0100 Subject: [PATCH 21/27] fix(live): close every gene of a closed deletion and list the fixed-panel cards A closed structural variant tagged only its first gene, so a row or a finding that named its second gene stayed open on a child's page. closable() now gives one span per gene the finding names, and each structural-variant finding lists its genes. veil.panels lists the cards whose rows are the same genes for everyone (FIXED_PANELS, one table), so the page can close them whole. Bead: pgp-iqon.28 Bead: pgp-iqon.29 --- bin/live_results.py | 41 +++++++++++++++++++++++++++---- tests/test_live_results.py | 50 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/bin/live_results.py b/bin/live_results.py index 3f0a3a603..629abda5c 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -888,6 +888,9 @@ def gene(x): 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)) + # Every gene it covers, not only the first: a closed one closes the rows that name any of them (closable()). + gl = x.get("genes") + out[-1]["genes"] = [str(g) for g in gl if g] if isinstance(gl, list) else [] # 6. Repeat expansions outside Stranger's "normal", or in an intermediate zone. for l in _items(_data(S, "expansions").get("loci")): with _Row(out, "expansions", "act"): @@ -1144,6 +1147,24 @@ def lead(found, levels, summary): # belongs to a closed result when it names the result's gene, or its position falls inside the result's # position or span; a row that is the result's own position or span belongs to that result first, else # the first such result in finding order. The veil lives in the browser: the files still hold every row. +# The cards whose rows are a fixed panel: the same genes or loci for everyone, known before the run. A row or +# dot of one that closes with a result would tell which gene the result is in by what is missing, so a child's +# page closes each of these cards whole while any result to act on is closed, behind one box with the same words +# whatever the result (results.json veil.panels; assets/live/person.js panelShut()). The one table: a card of a +# fixed panel is listed here and nowhere else. +FIXED_PANELS = { + "expansions": "the repeat loci of the catalog", + "vntyper": "MUC1", + "lpa": "LPA", + "chip": "the CHIP genes and their detection limits", + "paralogs": "SMN1, SMN2, HBA1, HBA2, the RCCX module and the other paralog genes", + "cpic": "the CPIC genes", + "cyp2d6": "CYP2D6", + "hla": "the HLA genes", + "locityper": "the Locityper loci", + "traits-panel": "the trait panel's genes", + "bloodtype": "the blood group systems", +} LOCUS_RE = re.compile(r"(? id; a symptom ranking can rank the gene) and the lead's segments that name one (lead: one per segment of LEAD_PARTS, the "act" names left out: the page closes those itself), each card's facts (facts: card -> one per fact) and the items - of its drawing (widget: card -> one per CHIP call or copy-number dot). spans is closable() itself: the family + of its drawing (widget: card -> one per CHIP call or copy-number dot), and the cards of a fixed panel + (panels: FIXED_PANELS, which close whole while any result is closed). spans is closable() itself: the family page (assets/live/family-page.js) reads which results are closed from it, and closes every place of the child that could carry one while one is. Empty when nothing can be closed.""" spans = closable(found) if not spans: return {} out = {"rows": {}, "viewer": {}, "lines": {}, "facts": {}, "widget": {}, "marks": {}, "findings": {}, "lead": [], - "spans": [list(s) for s in spans]} + "spans": [list(s) for s in spans], + "panels": [c["id"] for ch in chapters for c in ch["sections"] if c["id"] in FIXED_PANELS and c.get("state") != "missing"]} S = summary.get("sections") or {} hits = [h for h in _items(_data(S, "clinvar").get("hits"))[:50]] tags = [veil_id(spans, str(h.get("gene") or ""), h.get("chrom"), h.get("pos")) if isinstance(h, dict) else None diff --git a/tests/test_live_results.py b/tests/test_live_results.py index f5bec4523..8faefe11f 100644 --- a/tests/test_live_results.py +++ b/tests/test_live_results.py @@ -48,12 +48,16 @@ first ones and its closed lead names none; a CHIP call, a copy-number dot and a fact that name a closed result's gene carry its id (the CHIP chart's panel of genes never does), and results.json carries the closed results as - veil.spans (the family page closes a child's places while one is closed). + veil.spans (the family page closes a child's places while one is closed); + a closed structural variant over two genes closes the rows and the finding + that name only its second gene, and veil.panels lists the cards of a fixed + panel (live_results.FIXED_PANELS). Each check runs again on a planted mistake and must fail there: 1 with VNTYPER's entry dropped, 2 with one card's caveat blanked, 3 with "ok" planted in a card and "All clear" in a script, 4 with the hits emptied, 5 with the results route registered without the login, 6 with a row printed as its Python dict, 7 with the Slivar adapter returning nothing, -8 with a veil rule that tags nothing (rows, lines, facts and drawings) and one that tags every row. A check +8 with a veil rule that tags nothing (rows, lines, facts and drawings), one that tags every row and one that tags +only a structural variant's first gene. A check that cannot fail is no check. Run: python3 tests/test_live_results.py @@ -464,6 +468,39 @@ def widget_problems(): return out +def sv_genes_problems(): + """What is wrong with the veil's tags of a closed structural variant over two genes: a ClinVar row and a carrier + row that name only its second gene (the hit sits elsewhere, so only the gene can tie it) carry its id, and so + does the carrier finding; veil.spans lists it once per gene; veil.panels lists the cards of a fixed panel.""" + S = {"annotsv": {"state": "ok", "data": {"rows": [ + {"sv": "chr17:43044295-43170245", "type": "deletion", "length": -125950, "acmg_class": "5", + "genes": ["BRCA1", "NBR2"], "gt": "0/1", "support": 2, "support_callers": ["Manta", "Delly"]}]}}, + "clinvar": {"state": "ok", "data": {"hits": [ + {"gene": "NBR2", "chrom": "chr1", "pos": 5, "ref": "A", "alt": "G", "significance": "Pathogenic"}, + {"gene": "GENE5", "chrom": "chr1", "pos": 9, "ref": "A", "alt": "G", "significance": "Pathogenic"}]}}, + "carrier": {"state": "ok", "data": {"genes_on_list": 2, "carriers": [ + {"gene": "NBR2", "variant": "c.1A>G", "genotype": "het", "significance": "Pathogenic"}]}}, + "paralogs": {"state": "ok", "data": {"genes": [{"gene": "SMN1", "copy_number": 2, "status": "called"}]}}} + res = live_results.build("T", {"sections": S}) + v = res.get("veil") or {} + sv = [f["id"] for f in res["findings"] if f["card"] == "structural-variants"] + car = [f["id"] for f in res["findings"] if f["card"] == "carrier-list"] + out = [] + if len(sv) != 1 or len(car) != 1: + return [f"the planted findings: {[f['id'] for f in res['findings']]}"] + if (v.get("rows") or {}).get("clinvar") != [sv[0], None]: + out.append(f"ClinVar rows tagged {(v.get('rows') or {}).get('clinvar')}, want the deletion's id on the NBR2 row") + if (v.get("rows") or {}).get("carrier-list") != [sv[0]]: + out.append(f"carrier rows tagged {(v.get('rows') or {}).get('carrier-list')}") + if (v.get("findings") or {}).get(car[0]) != sv[0]: + out.append(f"the NBR2 carrier finding tagged {(v.get('findings') or {}).get(car[0])}") + if sorted(s[1] for s in v.get("spans") or [] if s[0] == sv[0]) != ["BRCA1", "NBR2"]: + out.append(f"spans {v.get('spans')}") + if v.get("panels") != ["paralogs"]: + out.append(f"panels {v.get('panels')}, want the copy-number card, the only fixed panel here") + return out + + def overview_problems(): """What is wrong with the home page's view of a child's run (overview): a finding that names a closed result (a symptom ranking of its gene) is listed among the first findings, or the closed lead still names it.""" @@ -584,6 +621,15 @@ def tagged(sample_dir): bad = widget_problems() check("8. a CHIP call, a copy-number dot and a fact that name a closed result's gene carry its id, the CHIP " "chart's gene panel none, and results.json carries the closed results as veil.spans", not bad, bad) + bad = sv_genes_problems() + check("8. a closed structural variant over two genes closes the rows and the finding that name only its second " + "gene, veil.spans lists it per gene, and veil.panels lists the fixed-panel cards", not bad, bad) + first = live_results.closable + try: + live_results.closable = lambda found: first([{k: v for k, v in f.items() if k != "genes"} for f in found]) + control("a veil that tags only a structural variant's first gene", not sv_genes_problems()) + finally: + live_results.closable = first bad = veil_problems(*tagged(d3)) check("8. the planted ClinVar hit's row, viewer link, tick and star bar carry its secondary finding's id, the " "deletion's span its own, every other card's row of the gene and the symptom ranking's finding one of them, " From 9ef50594e0ac18a3c75bb810de6b9c5d4e216cb3 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:16:28 +0100 Subject: [PATCH 22/27] fix(live): make a child's page the same whichever result is closed A closed finding's element and the address carried its id, which names the gene and variant. They now carry a handle, its place among the page's closed findings, and a link to #f/ lands on the closed card with the handle in the address. A fixed-panel card closes whole while any result is closed, and the note on a card's closed rows no longer counts them. The contents mark the section the address names on every redraw, so that mark no longer depends on timing. tests/test_live_demo.py compares two pages of the son that differ only in which result is closed (HTML, attributes, address, map canvas), with controls for the id, the whole-card rule and the count, and its map check no longer dies on an unbound k when nothing is closed. Bead: pgp-iqon.27 Bead: pgp-iqon.29 --- assets/live/person.js | 88 +++++++++-- tests/test_live_demo.py | 329 ++++++++++++++++++++++++++++++++-------- 2 files changed, 340 insertions(+), 77 deletions(-) diff --git a/assets/live/person.js b/assets/live/person.js index b4363b225..6e7cbb039 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -362,6 +362,32 @@ return [facts, w, shut]; } function veiledOf(c) { return (c.findings || []).map(findingById).filter(function (f) { return f && veiled(f); }); } + // The results to act on this page keeps closed, drawn yet or not. + function closedActs() { return ((results && results.findings) || []).filter(function (f) { return veiled(f) && f.level === 'act'; }); } + // A card of a fixed panel (results.veil.panels, bin/live_results.py FIXED_PANELS: the same genes or loci for + // everyone, known in advance): a row or dot of it closed with a result would tell the result's gene by what is + // missing. So it closes whole while this page keeps any result to act on closed, behind one box with the same + // words whatever the result, and comes back once the last one is opened. + function panelShut(c) { + var p = (results && results.veil && results.veil.panels) || []; + return closedPage() && p.indexOf(c.id) >= 0 && closedActs().length > 0; + } + var PANEL_WORDS = 'This section lists the same genes for everyone, so it stays closed while a result to act on is closed ' + + 'on this page: what it left out would tell which gene. It opens once every such result is opened.'; + var PANEL_LINE = 'The same genes for everyone, closed while a result about a child is closed.'; + // The next closed result in the page's order (the map's Open opens the same one). + function nextClosed() { var ids = mapClosed(); return ids.length ? findingById(ids[0]) : closedActs()[0]; } + // A closed finding's handle in the page: its place in the page's list of closed findings, never its id, which + // names its gene and variant. The id comes back once the finding is opened on purpose (#f/ too). + function closedOrder() { + var out = []; + sectionsList().forEach(function (sec) { + (sec.findings || []).forEach(function (id) { var f = findingById(id); if (f && veiled(f) && out.indexOf(id) < 0) { out.push(id); } }); + }); + ((results && results.findings) || []).forEach(function (f) { if (veiled(f) && out.indexOf(f.id) < 0) { out.push(f.id); } }); + return out; + } + function handle(f) { return veiled(f) ? 'closed-' + (closedOrder().indexOf(f.id) + 1) : f.id; } // The search box (plugins/search.js) asks the same question for each hit, by the summary key and the anchor // of the section it comes from: a hit from a card this page keeps closed is left out. A closed page with // no results.json yet cannot tell which cards those are, so it leaves every hit out until it can. @@ -372,7 +398,7 @@ if (!results || key === null) { return true; } if (tag && tagClosed(tag)) { return true; } return cards().some(function (c) { - return (c.id === anchor || (c.keys || []).indexOf(key) >= 0) && veiledOf(c).length > 0; + return (c.id === anchor || (c.keys || []).indexOf(key) >= 0) && (veiledOf(c).length > 0 || panelShut(c)); }); }; @@ -419,6 +445,9 @@ add(text, U.veil((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.', null, function () { openFinding(closedFs[0]); redraw(true); var x = document.getElementById('f-' + closedFs[0].id) || document.getElementById('card-' + c.id); if (x) { x.scrollIntoView({ block: 'start' }); } })); + } else if (panelShut(c)) { + // One button opens one result, the next closed one in the page's order, as the map's Open does. + add(text, U.veil(PANEL_WORDS, null, function () { var f = nextClosed(); if (f) { openFinding(f); } redraw(true); })); } else { (c.notes || []).forEach(function (n) { add(text, el('p', { 'class': 'note-stale' }, n)); }); if (c.headline) { add(text, el('p', { 'class': 'headline' }, c.headline)); } @@ -435,12 +464,9 @@ var shut = ot[1] + (c.lines || []).length - lines.length, items = of[2]; if (tb) { add(text, tb); } else if (c.empty_text && !ot[1]) { add(text, el('p', { 'class': 'empty' }, c.empty_text)); } if (shut || items) { - // Rows of its table and lines, and items of its facts and drawing, counted apart. - var what = [shut ? (shut === 1 ? 'One row' : shut + ' rows') : '', items ? (items === 1 ? (shut ? 'one item' : 'One item') : items + ' items') + - ' of its numbers or drawing' : ''].filter(Boolean).join(' and '); - var many = shut + items > 1; - add(text, el('p', { 'class': 'dim veil-rows' }, what + (many ? ' stay' : ' stays') + - ' closed with the result to act on ' + (many ? 'they belong' : 'it belongs') + ' to, until that result is opened.')); + // The same words however many rows or items close, and whichever result they belong to: a count would + // tell one closed result from another. + add(text, el('p', { 'class': 'dim veil-rows' }, 'Some of its rows or numbers stay closed with a result to act on, until that result is opened.')); } add(text, lineList(lines)); if (c.hidden) { @@ -465,7 +491,7 @@ add(foot, el('a', { href: 'report.html#' + encodeURIComponent(c.anchor), 'class': 'report' }, 'Open in the report')); var vtags = veilTags('viewer', c.id); var reads = (c.viewer || []).filter(function (x, i) { return !(vtags[i] && tagClosed(vtags[i])); }); - if (g === 'show' && reads.length) { + if (g === 'show' && reads.length && !panelShut(c)) { add(foot, el('a', { href: 'viewer/#' + reads[0], 'class': 'viewer' }, 'See the reads at ' + reads[0])); } (g === 'show' ? c.links || [] : []).forEach(function (f) { @@ -545,7 +571,29 @@ var t = findingTag(f); return !!(t && tagClosed(t)); } - function openFinding(f) { shown[f.id] = true; var t = findingTag(f); if (t) { shown[t] = true; } } + function openFinding(f) { keepHash(function () { shown[f.id] = true; var t = findingTag(f); if (t) { shown[t] = true; } }); } + // The address never keeps a closed finding's id. #f/ of a finding this page keeps closed (a link from another + // page, or a finding closed again since) becomes #f/closed-, its handle, and #f/ again once it is opened. + // Opening or closing a result moves the handles of the others: the address follows the finding it named. The + // finding behind a handle in the address is kept here only (hashId); a new address from the reader forgets it. + var hashId = null; + window.addEventListener('hashchange', function () { hashId = null; }); + function hashFinding() { + var h; + try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return null; } + if (h.indexOf('f/') !== 0) { return null; } + var x = h.slice(2); + if (/^closed-\d+$/.test(x)) { return (hashId && findingById(hashId)) || findingById(closedOrder()[Number(x.slice(7)) - 1]) || null; } + return findingById(x) || null; + } + function syncHash() { + var f = hashFinding(); + if (!f || !window.history || !window.history.replaceState) { return; } + hashId = f.id; + var to = '#f/' + handle(f); + if (window.location.hash !== to) { window.history.replaceState(null, '', to); } + } + function keepHash(change) { var at = hashFinding(); if (at) { hashId = at.id; } change(); syncHash(); } // The genome map's veil (map.js): 'closed' while this page keeps the result closed, 'opened' once it was // opened on purpose, 'open' when this page never closes it. A gated result (APOE, behind its warning) is // closed on every page, an adult's too, until opened; a tag naming no finding of these results stays closed. @@ -561,8 +609,10 @@ function setShown(id, on) { var f = findingById(id); if (on) { if (f) { openFinding(f); } else { shown[id] = true; } } else { - delete shown[id]; - ((results && results.findings) || []).forEach(function (x) { if (findingTag(x) === id) { delete shown[x.id]; } }); + keepHash(function () { + delete shown[id]; + ((results && results.findings) || []).forEach(function (x) { if (findingTag(x) === id) { delete shown[x.id]; } }); + }); } redraw(true); } @@ -596,7 +646,7 @@ } 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 art = el('div', { 'class': 'finding', id: 'f-' + handle(f), 'data-finding': handle(f), '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.findingLevel(f.level, f.level_words)); @@ -632,7 +682,7 @@ return row; } function findingLine(f) { - var li = el('li', { id: 'f-' + f.id, 'data-finding': f.id, 'data-level': f.level }); + var li = el('li', { id: 'f-' + handle(f), 'data-finding': handle(f), 'data-level': f.level }); if (veiled(f)) { // A finding that names a closed result (a symptom ranking of its gene): closed with it, opened with it. var b = el('button', { type: 'button', 'class': 'btn' }, 'Show the result'); @@ -766,6 +816,7 @@ function cardLine(c) { var g = gate(c); return g === 'show' && veiledOf(c).length ? 'A result about a child, closed until opened.' + : g === 'show' && panelShut(c) ? PANEL_LINE : g === 'show' ? (c.headline || (openFacts(c)[0][0] ? openFacts(c)[0][0][0] + ': ' + openFacts(c)[0][0][1] : NOT_ALL_CLEAR)) : pending(g) ? 'Waiting for ' + stepWords(c) + '.' : g === 'failed' ? 'Not assessed: its step failed.' : 'Not assessed in this run.'; } @@ -1104,7 +1155,7 @@ 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('f/') === 0) { var hf = hashFinding(); target = document.getElementById('f-' + (hf ? handle(hf) : h.slice(2))); } else if (h.indexOf('q/') === 0) { target = document.getElementById('s' + (CHAPTER_SECTION[h.slice(2)] || 1)); } else { target = document.getElementById(h); } return target ? { target: target, isCard: isCard } : null; @@ -1124,12 +1175,18 @@ } } function route() { + syncHash(); var t = routeTarget(); if (!t) { return; } var target = t.target; openAround(t); target.scrollIntoView({ block: 'start' }); if (target.focus) { target.focus({ preventScroll: true }); } + markToc(target); + } + // The contents mark the section the address names; a redraw marks it again, so the mark never depends on + // whether the page drew again after the address was read. + function markToc(target) { 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'); @@ -1205,6 +1262,9 @@ // A gated result's fold follows its switch, whatever fold state the reader left (setShown). Array.prototype.forEach.call(panel.querySelectorAll('details[data-gate]'), function (d) { d.open = !!shown[d.getAttribute('data-gate')]; }); window.scrollTo(0, y); + syncHash(); + var rt = routeTarget(); + if (rt) { markToc(rt.target); } 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.'; // The step timeline closes the report, folded once the page has a report to show, while the run goes diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 02fbf1b47..487fa3d2e 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -77,9 +77,14 @@ opened from its box, the same page whichever result is closed, and with his results unreadable "Show the rows" leaves it closed and "Show the map" opens it and no table; on - his page a CHIP call, a copy-number dot and a card's fact in the gene of a - closed result stay out of sight (on a phone the card's line too) until it - is opened; DEMO-001's page + his page the copy-number and CHIP cards, a fixed panel, close whole while + a result of his is closed (on a phone their line too), their box opens one + result per click and every dot, call and fact is back once all are open; + two of his pages that differ only in which result is closed (two SNV + genes, two deletions of two genes each, SMN1 against HBA1, a result with a + place on the map against one without, one ClinVar row against two, a link + to each) are the same before any click, HTML, attributes, address and map + canvas, and a closed deletion's second gene is named nowhere; DEMO-001's page mid-replay and at the end leads with the report (a short run band above the lead while it runs, none once finished), ends with the step timeline folded, keeps the wire to at most eight one-sentence lines with the plain @@ -99,8 +104,9 @@ request read as nothing closed, malformed veil.spans read as given, results without veil.spans read as nothing closed or as closed with nothing to act on, an opened result kept per run only or per child only, a couple screen closed only for a result in its genes or opened by 'Show the cells', a map image closed only for a result with a -position, and de novo rows that 'Show the map' opens; for the son's page, person.js that draws -every item, shows every fact or builds a card's line from every fact), the phone page (person.js that never folds, and a route that opens only the nearest fold), the +position, and de novo rows that 'Show the map' opens; for the son's page, person.js that draws a fixed-panel +card's open rows or builds its line from its facts, that puts a closed card's id back or counts the closed rows, +and a veil that tags only a deletion's first gene), the phone page (person.js that never folds, and a route that opens only the nearest fold), the folds (a flagged section 5 card folded, a redraw that forgets the folds, a Ctrl+click that opens one) and the person page's layout (a wire showing every line or every plain finish, a card showing every line, a step timeline left open) also run against a planted mistake and must @@ -110,6 +116,7 @@ """ import base64 import copy +import difflib import gzip import hashlib import json @@ -358,6 +365,7 @@ def browser_checks(out): map_checks(b, base, out) family_veil_checks(b, base, out) widget_veil_checks(b, base, out) + infer_checks(b, base, out) finally: b.close() srv.shutdown() @@ -402,8 +410,9 @@ def person_checks(b, base): 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), - # five copy-number genes from Paraphase, then Parascopy's SMN1 and SMN2 - ("DEMO-003", "paralogs", "copies", "[data-widget=copies] li", 7), + # five copy-number genes from Paraphase, then Parascopy's SMN1 and SMN2, on the partner's page: the son's keeps + # the copy-number card closed while a result of his is closed (a fixed panel) + ("DEMO-002", "paralogs", "copies", "[data-widget=copies] li", 7), ("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)] @@ -636,19 +645,32 @@ def planted_closed(b, base): return out -def planted_problems(b, base): +def open_closed(b, fid): + """Opens the closed result FID as a reader who holds a link to it does: #f/ lands on its closed card, whose + address and element carry its handle (#f/closed-), never its id; then its "Show the result". False when the + link lands on no closed card.""" + b.eval("location.hash = %s; true" % json.dumps("#f/" + fid)) + if not b.wait("/^#f\\/closed-\\d+$/.test(location.hash) && document.getElementById('f-' + location.hash.slice(3)) " + "&& location.hash", 5): + return False + b.eval("document.getElementById('f-' + location.hash.slice(3)).querySelector('.veil button').click(); true") + return True + + +def planted_problems(b, base, out_dir): """[problems] of the planted results on the son's page: closed, then opened one at a time, then reloaded.""" gene, hit, sv = planted_words() out = planted_closed(b, base) - ids = b.eval("Array.prototype.map.call(document.querySelectorAll('[data-finding][data-level=act]'), " - "function (e) { return e.getAttribute('data-finding'); }).join(' ')") or "" + with open(os.path.join(out_dir, "runs", OTHERS[1], "results.json")) as f: + ids = " ".join(x["id"] for x in json.load(f)["findings"] if x["level"] == "act") sf = next((i for i in ids.split() if i.startswith("sf-")), None) import make_demo_sample start = str(make_demo_sample.PLANTED["sv"][1]) del_ = next((i for i in ids.split() if i.startswith("sv-") and start in i), None) if not sf or not del_: return out + [f"the planted results are not among the son's results to act on: {ids}"] - b.eval("document.querySelector('#f-%s .veil button').click(); true" % sf) + if not open_closed(b, sf): + return out + [f"a link to {sf} lands on no closed card"] time.sleep(0.8) shown = b.eval(SHOWN_JS) or "" rows = b.eval("Array.prototype.filter.call(document.querySelectorAll('#card-clinvar tbody tr'), function (r) { " @@ -665,7 +687,8 @@ def planted_problems(b, base): hits, _ = son_search(b, gene) if hits in (None, "0"): out.append(f"the search for {gene} finds nothing once its result is opened") - b.eval("document.querySelector('#f-%s .veil button').click(); true" % del_) + if not open_closed(b, del_): + return out + [f"a link to {del_} lands on no closed card"] time.sleep(0.8) shown = b.eval(SHOWN_JS) or "" spans = b.eval("document.querySelectorAll('figure[data-chart=genome] a[data-mark^=sv-]').length") @@ -730,7 +753,7 @@ def write(p, text): with open(p, "w") as f: f.write(text) try: - bad = planted_problems(b, base) + bad = planted_problems(b, base, out) check("browser: the son's planted secondary finding and class 5 deletion name their gene and positions nowhere " "(cards, charts, titles, links, search) until each is opened, each opens only its own rows and marks, " "and a reload closes them", not bad, bad) @@ -1399,10 +1422,174 @@ def planted_control(desc, a, z, problems): write(p, text) +# What a reader of the son's page gets before any click: the whole document (every text and every attribute, the +# map's timing attributes aside), the address, and a hash of the map's canvas. +PAGE_JS = r"""JSON.stringify((function () { + var c = document.documentElement.cloneNode(true); + Array.prototype.forEach.call(c.querySelectorAll('[data-pgp-map-parse-ms],[data-pgp-map-paint-ms]'), function (e) { + e.removeAttribute('data-pgp-map-parse-ms'); e.removeAttribute('data-pgp-map-paint-ms'); }); + var cv = document.querySelector('.gm-canvas'); + return { html: c.outerHTML, href: location.href, canvas: cv ? cv.toDataURL() : '' }; +}()))""" + + +def page_now(b, base, hash_=""): + """The son's page before any click, once its results, its charts and its map have drawn: (lines of its HTML, + its address, its canvas hash).""" + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{OTHERS[1]}/{hash_}") + b.wait("var d = document.documentElement.dataset; d.pgpResults && d.pgpCharts && d.pgpStatus === 'ready' && (" + MAP_READY + + ") && 'ready'", 15) + time.sleep(0.8) # the margin follows the camera 140 ms after it settles + got = json.loads(b.eval(PAGE_JS) or "{}") + return (got.get("html") or "").replace("><", ">\n<").split("\n"), got.get("href") or "", \ + hashlib.sha256((got.get("canvas") or "").encode()).hexdigest()[:12] + + +def plant_summary(out, og, change): + """The son's results.json built again by live_results from his summary.json with CHANGE(sections) applied, and + his map.json built again from it by og map build. Returns what to write back afterwards.""" + son = OTHERS[1] + rj, mj = (os.path.join(out, "runs", son, n) for n in ("results.json", "map.json")) + keep = {} + for p in (rj, mj): + with open(p, "rb") as fh: + keep[p] = fh.read() + root = os.path.join(os.path.dirname(out), "root") + with open(os.path.join(root, son, "summary.json")) as fh: + summary = json.load(fh) + change(summary.setdefault("sections", {})) + res = live_results.build(son, summary) + with open(rj, "w") as fh: + json.dump(res, fh) + doc = build_live_demo.og_map(og, ["build", "--root", root, "--run", son, "--assets", os.path.join(REPO, "assets"), + "--cache-dir", os.path.join(root, ".owngenome-live", "map")], + json.dumps({"results": res}).encode()) + with open(mj, "wb") as fh: + fh.write(doc) + return keep + + +def cpsr(gene): + """A CPSR result to act on in GENE, the same variant whatever the gene: a closed result known by its gene.""" + def change(S): + S["cpsr"] = {"state": "ok", "data": {"call_floor_dp": 10, "low_confidence": 0, "plp": [ + {"gene": gene, "variant": "c.68_69del", "final": "Pathogenic", "clinvar": "Pathogenic"}]}} + return change + + +def sv_over(locus, genes): + """A class 5 deletion over GENES, and a carrier row that names only its second gene.""" + def change(S): + rows = S["annotsv"]["data"]["rows"] + S["annotsv"]["data"]["rows"] = rows + [{"sv": locus, "type": "DEL", "gt": "0/1", "length": "-100000", + "acmg_class": "5", "genes": genes, "caller": "Manta", "support": 1, + "support_callers": ["Manta"]}] + S["carrier"]["data"]["carriers"] = [{"gene": genes[1], "variant": "c.100C>T", "genotype": "het", + "significance": "Pathogenic", "stars": 2, "inheritance": "AR"}] + return change + + +def second_hit(S): + """A second ClinVar row in the gene of the son's planted secondary finding, the card's counts kept.""" + S["clinvar"]["data"]["hits"] = S["clinvar"]["data"]["hits"] + [dict( + [h for h in S["clinvar"]["data"]["hits"] if h.get("gene") == "KCNH2"][0], pos=150970000)] + + +# Pairs of son's pages that differ only in which result is closed (or in what only a closed result names): the +# page before any click must be the same, byte for byte. +INFER = (("two SNV results in different genes (BRCA1, BRCA2)", cpsr("BRCA1"), cpsr("BRCA2")), + ("a deletion over BRCA1 and NBR2 against one over CFTR and CTTNBP2", + sv_over("chr17:43044295-43144295", ["BRCA1", "NBR2"]), sv_over("chr7:117480025-117580025", ["CFTR", "CTTNBP2"])), + ("SMN1 against HBA1, the same copy numbers", cpsr("SMN1"), cpsr("HBA1")), + ("a result with a place on the map (BRCA2) against one without (MLH1)", cpsr("BRCA2"), cpsr("MLH1")), + ("a closed result with one ClinVar row against two", lambda S: None, second_hit), + ("a link to each of two closed results (#f/)", cpsr("BRCA1"), cpsr("BRCA2"), + ("#f/cpsr-brca1-c-68-69del", "#f/cpsr-brca2-c-68-69del"))) + + +def infer_problems(b, base, out, og, cases=INFER): + """[problems]: for each pair of CASES, the son's page before any click differs (its HTML, an attribute, the + address or the map's canvas), with the number of differing lines and the first ones.""" + bad = [] + for what, one, two, *hashes in cases: + got = [] + for change, h in zip((one, two), (hashes or [("", "")])[0]): + keep = plant_summary(out, og, change) + try: + got.append(page_now(b, base, h)) + finally: + unplant(keep) + (h1, a1, c1), (h2, a2, c2) = got + diff = [x for x in difflib.unified_diff(h1, h2, lineterm="", n=0) if x[:1] in "+-" and x[:3] not in ("+++", "---")] + if diff or a1 != a2 or c1 != c2: + bad.append(f"{what}: {len(diff)} lines differ, address {a1 == a2}, canvas {c1 == c2}: {diff[:6]}") + return bad + + +def sv_gene_problems(b, base, out, og): + """[problems]: with a closed deletion over BRCA1 and NBR2, the page names NBR2 nowhere (a carrier row and its + finding name only NBR2) before any click.""" + keep = plant_summary(out, og, sv_over("chr17:43044295-43144295", ["BRCA1", "NBR2"])) + try: + html = "\n".join(page_now(b, base)[0]) + finally: + unplant(keep) + return [f"NBR2 on the page: ...{html[max(0, html.find('NBR2') - 120):html.find('NBR2') + 40]}..."] if "NBR2" in html else [] + + +def infer_checks(b, base, out): + """The son's page is a function of his open results and the count of his closed ones: two pages that differ only + in which result is closed are the same before any click (INFER), and a closed deletion's second gene is named + nowhere. Controls: person.js that puts a closed card's id back, that draws a fixed-panel card's open rows, or + that counts the closed rows; a veil that tags only a deletion's first gene.""" + og = og_binary() or build_live_demo.OG + if not os.path.isfile(os.path.join(out, "runs", OTHERS[1], "map.json")): + print("[SKIP] infer checks: the demo has no map.json") + return + t0 = time.time() + bad = infer_problems(b, base, out, og) + check("browser: two pages of the son that differ only in which result is closed are the same before any click, " + "HTML, attributes, address and map canvas (" + "; ".join(x[0] for x in INFER) + ")", not bad, bad) + bad = sv_gene_problems(b, base, out, og) + check("browser: a closed deletion over two genes closes the rows that name only its second gene", not bad, bad) + js = os.path.join(out, "assets", "person.js") + with open(js) as fh: + keep_js = fh.read() + try: + for desc, a, z, case in ( + ("person.js that puts a closed card's id back", + "function handle(f) { return veiled(f) ? 'closed-' + (closedOrder().indexOf(f.id) + 1) : f.id; }", + "function handle(f) { return f.id; }", INFER[0]), + ("person.js that draws a fixed-panel card's open rows", "} else if (panelShut(c)) {", "} else if (false) {", + INFER[2]), + ("person.js that counts the closed rows", + "'Some of its rows or numbers stay closed with a result to act on, until that result is opened.'", + "(ot[1] + (c.lines || []).length - lines.length) + ' rows stay closed with a result to act on.'", INFER[4])): + if a not in keep_js: + check(f"control: {desc} (the planted line is in person.js)", False, a) + continue + with open(js, "w") as fh: + fh.write(keep_js.replace(a, z)) + control(desc, not infer_problems(b, base, out, og, [case])) + finally: + with open(js, "w") as fh: + fh.write(keep_js) + first = live_results.closable + try: + live_results.closable = lambda found: first([{k: v for k, v in f.items() if k != "genes"} for f in found]) + control("a veil that tags only a deletion's first gene", not sv_gene_problems(b, base, out, og)) + finally: + live_results.closable = first + print(f" infer checks: {time.time() - t0:.0f} s") + + def widget_veil_checks(b, base, out): - """On the son's page a CHIP call and a copy-number dot in the gene of a closed result stay out of sight until that - result is opened; the other dot stays drawn. A CHIP call is also a finding of its card that names its gene, so - the CHIP card closes whole with the result; SMN1 at two copies is no finding, so only its dot's tag closes it.""" + """On the son's page the copy-number card and the CHIP card, whose rows are a fixed panel (the same genes for + everyone), close whole while any result of his to act on is closed: no dot, no call, no fact of theirs shows, + and their line on a phone is the same words whatever the result. Each "Show the result" of their box opens + one result; once every result is opened every dot, call and fact is back.""" no_cache(b) rj = os.path.join(out, "runs", OTHERS[1], "results.json") js = os.path.join(out, "assets", "person.js") @@ -1416,12 +1603,11 @@ def drawn(): 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('[data-widget=copies]') && document.querySelector('.veil') && 'drawn'", 15) + "&& document.querySelector('#card-paralogs') && document.querySelector('.veil') && 'drawn'", 15) return shown() def phone_line(): - """The copy-number card's one line on a phone (its fold's summary, after the card's own title, the same for - everyone), drawn from its first open fact.""" + """The copy-number card's one line on a phone (its fold's summary, after the card's own title).""" b.call("Emulation.setDeviceMetricsOverride", {"width": 390, "height": 844, "deviceScaleFactor": 2, "mobile": False}, b.session) try: @@ -1431,26 +1617,25 @@ def phone_line(): return b.wait("document.documentElement.dataset.pgpStatus === 'ready' && document.documentElement.dataset.pgpResults " "&& (document.querySelector('[data-card=fold-paralogs] summary .what') || {}).textContent", 15) or "" finally: - b.call("Emulation.clearDeviceMetricsOverride", {}, b.session) + b.call("Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 900, "deviceScaleFactor": 1, "mobile": False}, + b.session) def phone_problems(): line = phone_line() - return [] if line and "SMN1" not in line else [f"the copy-number card's line on a phone: {line!r}"] + want = "The same genes for everyone, closed while a result about a child is closed." + return [] if line == want else [f"the copy-number card's line on a phone: {line!r}, want {want!r}"] def shown(): - return b.eval("JSON.stringify([" + SHOWN_JS + ", (document.querySelector('[data-widget=copies]') || {}).textContent || ''])") + return b.eval("JSON.stringify([" + SHOWN_JS + ", (document.querySelector('[data-widget=copies]') || {}).textContent || '', " + "document.querySelectorAll('.finding[data-level=act] .veil').length])") def problems(text, opened): - page, cops = json.loads(text) if text else ["", ""] + page, cops, _ = json.loads(text) if text else ["", "", 0] out_ = [] - if "SMN2" not in cops: - out_.append(f"the open dot is not drawn: {cops[:120]}") - if ("p.R175H" in page) != opened or ("SMN1" in cops) != opened: - out_.append(f"the CHIP call or the SMN1 dot of the closed results {'not ' if opened else ''}shown: " - f"{'p.R175H' in page} | {cops[:200]}") - # The copy-number card's facts name SMN1: closed in its margin and in its line of section 8. - if ("SMN1 copies" in page) != opened: - out_.append(f"the card's fact on SMN1 {'not ' if opened else ''}shown") + if opened and not ("SMN1" in cops and "SMN2" in cops and "p.R175H" in page and "SMN1 copies" in page): + out_.append(f"every result opened, yet a dot, the call or the fact is missing: {cops[:200]}") + if not opened and (cops or "p.R175H" in page or "SMN1 copies" in page): + out_.append(f"a closed result, yet the copy-number or CHIP card shows its rows: {cops[:200]}") return out_ try: with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: @@ -1470,19 +1655,23 @@ def problems(text, opened): json.dump(res, f) bad = problems(drawn(), False) + phone_problems() if not bad: - for _ in range(2): # the CPSR card holds both results: one click opens one - b.eval("document.querySelector('#card-cpsr .veil button').click(); true") - time.sleep(0.5) - bad = problems(shown(), True) - check("browser: on the son's page a CHIP call, a copy-number dot and a card's fact in the gene of a closed " - "result stay out of sight (the fact in the card's line on a phone too) until it is opened", not bad, bad) + # The copy-number card's box opens one result per click, until none is left closed. + n = json.loads(shown())[2] + for k in range(n): + b.eval("var v = document.querySelector('#card-paralogs .veil button'); v && v.click(); true") + time.sleep(0.3) + left = json.loads(shown())[2] + if left != n - k - 1: + bad.append(f"click {k + 1} on the copy-number card's box left {left} of {n} results closed") + break + bad += problems(shown(), True) + check("browser: on the son's page the copy-number and CHIP cards, a fixed panel, close whole while a result is " + "closed (their line on a phone too), their box opens one result per click, and every dot, call and fact is " + "back once every result is opened", not bad, bad) for desc, a, z in ( - ("person.js that draws every item", "add(text, widget(of[1]));", "add(text, widget(c.widget));"), - ("person.js that shows every fact", "facts = (c.facts || []).filter(function (f, i) { return !(ft[i] && tagClosed(ft[i])); });", - "facts = c.facts || [];"), - ("person.js whose card line reads every fact", - "(openFacts(c)[0][0] ? openFacts(c)[0][0][0] + ': ' + openFacts(c)[0][0][1] : NOT_ALL_CLEAR)", - "(c.facts && c.facts[0] ? c.facts[0][0] + ': ' + c.facts[0][1] : NOT_ALL_CLEAR)")): + ("person.js that draws a fixed-panel card's open rows", "} else if (panelShut(c)) {", "} else if (false) {"), + ("person.js whose card line reads a fixed-panel card's facts", ": g === 'show' && panelShut(c) ? PANEL_LINE", + ": false ? PANEL_LINE")): if a not in keep_js: check(f"control: {desc} (the planted line is in person.js)", False, a) continue @@ -2053,15 +2242,17 @@ def map_now(b): # The page's closed results to act on and the map's chips, read at once (a chip's button rebuilds the chips at -# once: nothing to wait for): the closed act cards in page order, the act chip's count, whether its confirm step +# once: nothing to wait for): the act cards in page order (ids: each one's id, its handle while it is closed) and +# the places of the closed ones among them (closed: a closed card names no id, so a result is known by its place), the act chip's count, whether its confirm step # shows, where the keyboard is (first: the chips' first button; canvas: the map), and the opened chips. CHIP_JS = r"""JSON.stringify((function () { - var ids = []; - Array.prototype.forEach.call(document.querySelectorAll('.finding[data-level=act]'), function (a) { - if (a.querySelector('.veil') && ids.indexOf(a.dataset.finding) < 0) { ids.push(a.dataset.finding); } }); + var ids = [], closed = []; + Array.prototype.forEach.call(document.querySelectorAll('.finding[data-level=act]'), function (a, i) { + ids.push(a.dataset.finding); + if (a.querySelector('.veil')) { closed.push(i); } }); var c = document.querySelector('.gm-chip[data-closed=act]'), f = document.activeElement; var first = document.querySelector('.gm-chips button'); - return { closed: ids, buttons: !!first, chip: c ? c.firstChild.textContent : '', confirm: !!(c && c.querySelector('.gm-confirm')), + return { closed: closed, ids: ids, buttons: !!first, chip: c ? c.firstChild.textContent : '', confirm: !!(c && c.querySelector('.gm-confirm')), focus: f && f.classList.contains('gm-yes') ? 'gm-yes' : f === first ? 'first' : f === document.querySelector('.gm-canvas') ? 'canvas' : (f ? f.tagName + '.' + f.className : ''), opened: Array.prototype.map.call(document.querySelectorAll('.gm-chip.opened'), function (o) { var a = o.querySelector('a'); @@ -2079,8 +2270,8 @@ def closed_words(n): def chip_open(b): - """The act chip's Open, then its Show the result, as a keyboard reader does it. Returns (the id it opened, - problems): one Open opens exactly one of the page's closed results, the count falls by one, the keyboard + """The act chip's Open, then its Show the result, as a keyboard reader does it. Returns (the id it opened, its + place among the act cards, problems): one Open opens exactly one of the page's closed results, the count falls by one, the keyboard goes to Show the result and then to the chips' first button (the map when no chip is left), and an "Opened in its section" chip never names a result that is still closed.""" before = chips_now(b) @@ -2101,10 +2292,15 @@ def chip_open(b): bad.append(f"Show the result: the chip reads {after['chip']!r}, want {closed_words(n)!r}") if after["focus"] != ("first" if after["buttons"] else "canvas"): bad.append(f"Show the result: the keyboard is on {after['focus']!r}") - named = [o["href"] for o in after["opened"] if o["kind"] == "section" and o["href"][3:] in after["closed"]] + named = [o["href"] for o in after["opened"] if o["kind"] == "section" and o["href"][3:] not in after["ids"]] if named: - bad.append(f"an Opened in its section chip names a closed result: {named}") - return (gone[0] if len(gone) == 1 else None), bad + bad.append(f"an Opened in its section chip names a result that is not open: {named}") + leaked = [after["ids"][i] for i in after["closed"] if not re.match(r"^closed-\d+$", after["ids"][i] or "")] + if leaked: + bad.append(f"a closed card carries an id, not a handle: {leaked}") + if len(gone) != 1: + return None, None, bad + return after["ids"][gone[0]], gone[0], bad def parity_problems(b, base, rows): @@ -2219,12 +2415,15 @@ def leaks(c, but=""): # Open opens the page's closed results one per click, in the page's order, so the son's own result with no # place (his first closed card) opens before the deletion: the map then names it only in its section chip. order = chips_now(b)["closed"] + if not want: + return bad + ["the son's page shows no closed result to open"] + k, at = 0, None for k in range(want): - got, more = chip_open(b) + got, at, more = chip_open(b) if more: return bad + [f"Open {k + 1}: {x}" for x in more] - if got != order[k]: - return bad + [f"Open {k + 1} opened {got}, not the page's next closed result {order[k]} ({order})"] + if at != order[k]: + return bad + [f"Open {k + 1} opened {got}, the act card at {at}, not the page's next closed one {order[k]} ({order})"] if got == fid: break section = [o for o in chips_now(b)["opened"] if o["kind"] == "section"] @@ -2252,10 +2451,10 @@ def leaks(c, but=""): bad.append(f"Close it again on the section chips: chips {chips_now(b)['opened']}, closed {chips_now(b)['closed']}") b.eval("document.querySelector('.gm-chip.opened[data-opened=\\'1\\'] button').click(); true") c = map_now(b) - card = b.eval("!!document.querySelector('#f-%s .veil')" % fid) + card = at in chips_now(b)["closed"] if leaks(c) or not card: bad.append(f"Close it again: map shows {leaks(c)}, card closed {card}") - b.eval("document.querySelector('#f-%s .veil button').click(); true" % fid) + b.eval("document.querySelectorAll('.finding[data-level=act]')[%d].querySelector('.veil button').click(); true" % at) c = map_now(b) if [m["id"] for m in c["marks"] if m["id"] == fid] != [fid]: bad.append("the card's Show the result does not open it on the map") @@ -2314,7 +2513,8 @@ def overlap_problems(b, base, out): c = map_page(b, base, son, "#" + f["chrom"][i]) if c["state"] != "drawn": return [f"the planted map: {c['state']}"] - b.eval("document.querySelector('#f-%s .veil button').click(); true" % fid) + if not open_closed(b, fid): + return [f"a link to {fid} lands on no closed card"] c = map_now(b) bad = [] if fid not in [m["id"] for m in c["marks"]]: @@ -2457,7 +2657,8 @@ def second_open_problems(b, base, out, og): ids = [m["id"] for m in c["marks"]] if "pl-alpha" in ids or "pl-beta" in ids: return [f"closed: marks {ids}"] - b.eval("document.querySelector('#f-pl-beta .veil button').click(); true") + if not open_closed(b, "pl-beta"): + return ["a link to pl-beta lands on no closed card"] c = map_now(b) ids = [m["id"] for m in c["marks"]] bad = [] @@ -2519,7 +2720,8 @@ def named_problems(b, base, out, og): if fig != before: bad.append(f"closed: the figure differs from the page without them " f"({[k for k in ('html', 'canvas') if fig.get(k) != before.get(k)]})") - b.eval("document.querySelector('#f-pl-x .veil button').click(); true") + if not open_closed(b, "pl-x"): + return bad + ["a link to pl-x lands on no closed card"] c = map_now(b) ids = [m["id"] for m in c["marks"]] if not {"pl-x", "pl-q", "pl-r"} <= set(ids): @@ -2581,7 +2783,7 @@ def open_order_problems(b, base, out, og): f"keyboard on {kept['focus']!r}"] seq = [] for _ in first["closed"]: - got, more = chip_open(b) + got, _, more = chip_open(b) if more: return bad + [f"{what}, Open {len(seq) + 1}: {x}" for x in more] seq.append(got) @@ -2592,10 +2794,11 @@ def open_order_problems(b, base, out, og): seqs[what] = seq if k == 0: gone = [x["id"] for x in extra if not x["viewer"]][0] + at = chips_now(b)["ids"].index(gone) if gone in chips_now(b)["ids"] else -1 b.eval("var o = document.querySelector('.gm-chip.opened[data-opened=section] a[href=\"#f/%s\"]'); " "o.parentNode.parentNode.querySelector('button').click(); true" % gone) after = chips_now(b) - if after["closed"] != [gone] or any(o["kind"] == "section" and gone in o["href"] for o in after["opened"]): + if after["closed"] != [at] or any(o["kind"] == "section" and gone in o["href"] for o in after["opened"]): bad.append(f"{what}: Close it again on {gone}'s section chip: closed {after['closed']}, " f"opened chips {after['opened']}") finally: From 283ee24573a19381e1ad868e0b4279a8f0ae2036 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:16:28 +0100 Subject: [PATCH 23/27] docs(live): say how a child's page keeps which result is closed to itself Bead: pgp-iqon.27 Bead: pgp-iqon.28 Bead: pgp-iqon.29 --- CHANGELOG.md | 1 + docs/live-site.md | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4dff4278..333b93b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Each release's notes. From 1.0.0 on, a major or minor release has hand-written n ## Unreleased - The family page now keeps a child's results to act on closed, as the child's own page does, and what it shows of a child no longer depends on which result is closed or where it lies. While a child has a result still closed, or the page cannot read the child's results, these close for that child, one box each: the painting's grandparents (every stretch drawn from its parent alone), the chromosome map image, all de novo and compound-het rows, the child's medicines and HLA cells in the side by side, and the carrier screen of a couple the child is part of. Each "Show the result" opens one result; the places come back once the last one is opened, and a reload closes them again. While the results cannot be read, each box opens only its own place. On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too, on a phone in the card's line as well. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: `results.json` and `family.json` hold every row. +- A child's page no longer tells which result to act on is closed. A closed finding's card, its place in the page and the address carry a handle (`#f/closed-2`) instead of its id, which names the gene and variant; a link to `#f/` lands on the closed card with the handle in the address, and the id comes back once the result is opened. A closed structural variant now closes the rows and findings that name any of its genes, not only the first. The cards whose rows are the same genes for everyone (repeat expansions, VNtyper, Lp(a), CHIP, the copy numbers, CPIC, CYP2D6, HLA, Locityper, the traits panel, the blood groups) close whole while any result is closed, behind one box, and a card's note on its closed rows no longer counts them. `results.json` gains `veil.panels`, a `genes` list on each structural-variant finding, and one `veil.spans` row per gene; nothing is renamed or removed. - Step 40, the 23andMe and AncestryDNA raw-data files, now runs on the Rust `og` binary: `og consumer export`, `og consumer build-sites` and `og consumer check` replace `bin/export_consumer.py`. On a synthetic gVCF of 552 MB (32 million records) with 617,044 sites it takes about 2 s and 43 MB in a Linux container on 4 cores, where the Python took 30 s and 660 MB; the files hold the same rows. `setup.sh --consumer-sites` reads dbSNP on 4 cores without the grep pass. The zips are now the same bytes for the same day (`--date`, else `SOURCE_DATE_EPOCH`, else today). Fixes: a call of three or more alleles stops the export with the site, instead of a file the strict reader refuses; two dbSNP records of one rsID at one position have their ALT alleles joined before the strand test; a bad site row or an unreadable zip is reported with the file and line instead of a traceback; a good export removes the `.txt` files a failed one left. The musl builds of `og` use the mimalloc allocator, which keeps a long multi-threaded read at its live memory. Step 40 needs `og`: `scripts/setup.sh ` builds it, and `scripts/setup.sh --og` builds only it. - Steps 21, 32 and 9b (and the CYRIUS, PYPGX and STRANGER_RFC1 modules) now run the CYP2D6 depth check and the RFC1 motif count with `og`, and `bin/cyp2d6_depth_check.py` and `bin/rfc1_motif.py` are gone. A run that reaches these steps needs the binary: run `./scripts/setup.sh --og` once after setup and after each pull; without it the step stops with `og is not built`. The output files, their names and formats do not change; in CI, every e2e case that runs these steps in bash checks `og` against the deleted Python, byte for byte. diff --git a/docs/live-site.md b/docs/live-site.md index b2b16746b..0710b9792 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -56,7 +56,12 @@ 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 its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows, lines and facts, a copy-number dot (a CHIP call is a finding of its own card, so the CHIP card closes whole), a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A count says how many rows stay closed, and opening the result opens them with it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens one result per click, the next closed result in the page's order (the order of its closed cards), so the order does not tell either whether a closed result has a place. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A result with no place on the map opens in its section only: the map draws nothing new, and a chip reads "Opened in its section:" with the result's title, a link to its card and a button to close it again. That chip names only a result that is open, and it goes when the result closes. After Open the keyboard moves to "Show the result", and after "Show the result" or "Keep it closed" to the chips' first button, or to the map when no chip is left. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. +A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows, lines and facts, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A structural variant names every gene it covers, so a row that names any of them closes with it. A card with such rows says that some of its rows stay closed, in the same words however many there are, and opening the result opens them with it. The pages differ only in what is open and in how many results are closed, never in which result is closed: + +- A card whose rows are a fixed panel, the same genes or loci for everyone, would tell the closed result's gene by the row it leaves out. So these cards close whole while any result to act on is closed, behind one box with the same words whatever the result: the repeat expansions, VNtyper, Lp(a), CHIP, the paralog copy numbers (SMN1, SMN2, HBA1, HBA2, the RCCX module), CPIC, CYP2D6, HLA, Locityper, the traits panel and the blood groups. The one list is `FIXED_PANELS` in `bin/live_results.py`, and `results.json` gives it as `veil.panels`. Their "Show the result" opens the next closed result, and the cards come back once every result is opened. The cost: a child's page with a closed result shows none of these cards until the results are opened. Their own findings (a medicine gene, a CHIP call) still show in their sections. +- A closed finding carries no id in the page or the address, since its id names its gene and variant. It is known by its place among the page's closed findings (`#f/closed-2`). A link to `#f/` of a closed result lands on its card, and the address becomes that handle. The id comes back once the result is opened, and goes again when it is closed. + +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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens one result per click, the next closed result in the page's order (the order of its closed cards), so the order does not tell either whether a closed result has a place. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A result with no place on the map opens in its section only: the map draws nothing new, and a chip reads "Opened in its section:" with the result's title, a link to its card and a button to close it again. That chip names only a result that is open, and it goes when the result closes. After Open the keyboard moves to "Show the result", and after "Show the result" or "Keep it closed" to the chips' first button, or to the map when no chip is left. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. While the traits card is closed whole on a child's page, the chip is the switch. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. A stopped run's page says so under the name and in the summary line, with the day it was last heard from, and warns that the report may be incomplete; a section still waiting for its step says the run stopped before that step ended. @@ -90,7 +95,7 @@ The reads cross the network to the browser that logged in: only the byte ranges `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; a finding that names a result to act on, such as a symptom ranking of its gene, is never among them), `lead` (its lead paragraph as plain text), `lead_closed` (the same with the names of the findings to act on left out, and every other part that names one, 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`, `databases` and `veil` (on a child's page, the rows outside a result's own card that close with it, and `spans`, the closed results with their gene and position, from which the family page knows whether a child has a result still closed). 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`, `databases` and `veil` (on a child's page, the rows outside a result's own card that close with it; `spans`, the closed results with their position, one row per gene they name, from which the family page knows whether a child has a result still closed; and `panels`, the cards of a fixed panel). A structural variant's finding also lists every gene it covers in `genes`. 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 From bd9fde0e4d9eac4fe721284bf3c22a01f6d55946 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:10:02 +0100 Subject: [PATCH 24/27] fix(live): close the known mobile element insertions card whole as well Its rows are the catalog of assets/mei_sites.tsv, the same for every reader, so a row closed with a result would name its gene. It joins FIXED_PANELS. Bead: pgp-iqon.29 --- CHANGELOG.md | 2 +- bin/live_results.py | 1 + docs/live-site.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333b93b80..b842bb3d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Each release's notes. From 1.0.0 on, a major or minor release has hand-written n ## Unreleased - The family page now keeps a child's results to act on closed, as the child's own page does, and what it shows of a child no longer depends on which result is closed or where it lies. While a child has a result still closed, or the page cannot read the child's results, these close for that child, one box each: the painting's grandparents (every stretch drawn from its parent alone), the chromosome map image, all de novo and compound-het rows, the child's medicines and HLA cells in the side by side, and the carrier screen of a couple the child is part of. Each "Show the result" opens one result; the places come back once the last one is opened, and a reload closes them again. While the results cannot be read, each box opens only its own place. On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too, on a phone in the card's line as well. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: `results.json` and `family.json` hold every row. -- A child's page no longer tells which result to act on is closed. A closed finding's card, its place in the page and the address carry a handle (`#f/closed-2`) instead of its id, which names the gene and variant; a link to `#f/` lands on the closed card with the handle in the address, and the id comes back once the result is opened. A closed structural variant now closes the rows and findings that name any of its genes, not only the first. The cards whose rows are the same genes for everyone (repeat expansions, VNtyper, Lp(a), CHIP, the copy numbers, CPIC, CYP2D6, HLA, Locityper, the traits panel, the blood groups) close whole while any result is closed, behind one box, and a card's note on its closed rows no longer counts them. `results.json` gains `veil.panels`, a `genes` list on each structural-variant finding, and one `veil.spans` row per gene; nothing is renamed or removed. +- A child's page no longer tells which result to act on is closed. A closed finding's card, its place in the page and the address carry a handle (`#f/closed-2`) instead of its id, which names the gene and variant; a link to `#f/` lands on the closed card with the handle in the address, and the id comes back once the result is opened. A closed structural variant now closes the rows and findings that name any of its genes, not only the first. The cards whose rows are the same genes for everyone (known mobile element insertions, repeat expansions, VNtyper, Lp(a), CHIP, the copy numbers, CPIC, CYP2D6, HLA, Locityper, the traits panel, the blood groups) close whole while any result is closed, behind one box, and a card's note on its closed rows no longer counts them. `results.json` gains `veil.panels`, a `genes` list on each structural-variant finding, and one `veil.spans` row per gene; nothing is renamed or removed. - Step 40, the 23andMe and AncestryDNA raw-data files, now runs on the Rust `og` binary: `og consumer export`, `og consumer build-sites` and `og consumer check` replace `bin/export_consumer.py`. On a synthetic gVCF of 552 MB (32 million records) with 617,044 sites it takes about 2 s and 43 MB in a Linux container on 4 cores, where the Python took 30 s and 660 MB; the files hold the same rows. `setup.sh --consumer-sites` reads dbSNP on 4 cores without the grep pass. The zips are now the same bytes for the same day (`--date`, else `SOURCE_DATE_EPOCH`, else today). Fixes: a call of three or more alleles stops the export with the site, instead of a file the strict reader refuses; two dbSNP records of one rsID at one position have their ALT alleles joined before the strand test; a bad site row or an unreadable zip is reported with the file and line instead of a traceback; a good export removes the `.txt` files a failed one left. The musl builds of `og` use the mimalloc allocator, which keeps a long multi-threaded read at its live memory. Step 40 needs `og`: `scripts/setup.sh ` builds it, and `scripts/setup.sh --og` builds only it. - Steps 21, 32 and 9b (and the CYRIUS, PYPGX and STRANGER_RFC1 modules) now run the CYP2D6 depth check and the RFC1 motif count with `og`, and `bin/cyp2d6_depth_check.py` and `bin/rfc1_motif.py` are gone. A run that reaches these steps needs the binary: run `./scripts/setup.sh --og` once after setup and after each pull; without it the step stops with `og is not built`. The output files, their names and formats do not change; in CI, every e2e case that runs these steps in bash checks `og` against the deleted Python, byte for byte. diff --git a/bin/live_results.py b/bin/live_results.py index 629abda5c..80c3954fb 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -1153,6 +1153,7 @@ def lead(found, levels, summary): # whatever the result (results.json veil.panels; assets/live/person.js panelShut()). The one table: a card of a # fixed panel is listed here and nowhere else. FIXED_PANELS = { + "mei": "the known disease-causing insertions of assets/mei_sites.tsv", "expansions": "the repeat loci of the catalog", "vntyper": "MUC1", "lpa": "LPA", diff --git a/docs/live-site.md b/docs/live-site.md index 0710b9792..0800efa7d 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -58,7 +58,7 @@ Every result carries one of six levels, with a word, a shape and a colour, so th A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows, lines and facts, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A structural variant names every gene it covers, so a row that names any of them closes with it. A card with such rows says that some of its rows stay closed, in the same words however many there are, and opening the result opens them with it. The pages differ only in what is open and in how many results are closed, never in which result is closed: -- A card whose rows are a fixed panel, the same genes or loci for everyone, would tell the closed result's gene by the row it leaves out. So these cards close whole while any result to act on is closed, behind one box with the same words whatever the result: the repeat expansions, VNtyper, Lp(a), CHIP, the paralog copy numbers (SMN1, SMN2, HBA1, HBA2, the RCCX module), CPIC, CYP2D6, HLA, Locityper, the traits panel and the blood groups. The one list is `FIXED_PANELS` in `bin/live_results.py`, and `results.json` gives it as `veil.panels`. Their "Show the result" opens the next closed result, and the cards come back once every result is opened. The cost: a child's page with a closed result shows none of these cards until the results are opened. Their own findings (a medicine gene, a CHIP call) still show in their sections. +- A card whose rows are a fixed panel, the same genes or loci for everyone, would tell the closed result's gene by the row it leaves out. So these cards close whole while any result to act on is closed, behind one box with the same words whatever the result: the known mobile element insertions, the repeat expansions, VNtyper, Lp(a), CHIP, the paralog copy numbers (SMN1, SMN2, HBA1, HBA2, the RCCX module), CPIC, CYP2D6, HLA, Locityper, the traits panel and the blood groups. The one list is `FIXED_PANELS` in `bin/live_results.py`, and `results.json` gives it as `veil.panels`. Their "Show the result" opens the next closed result, and the cards come back once every result is opened. The cost: a child's page with a closed result shows none of these cards until the results are opened. Their own findings (a medicine gene, a CHIP call) still show in their sections. - A closed finding carries no id in the page or the address, since its id names its gene and variant. It is known by its place among the page's closed findings (`#f/closed-2`). A link to `#f/` of a closed result lands on its card, and the address becomes that handle. The id comes back once the result is opened, and goes again when it is closed. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens one result per click, the next closed result in the page's order (the order of its closed cards), so the order does not tell either whether a closed result has a place. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A result with no place on the map opens in its section only: the map draws nothing new, and a chip reads "Opened in its section:" with the result's title, a link to its card and a button to close it again. That chip names only a result that is open, and it goes when the result closes. After Open the keyboard moves to "Show the result", and after "Show the result" or "Keep it closed" to the chips' first button, or to the map when no chip is left. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. While the traits card is closed whole on a child's page, the chip is the switch. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. From a80d92cd71c5bb20328cf2a8753d683b25607c33 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:53:27 +0100 Subject: [PATCH 25/27] fix(live): close the ClinVar card's deep intronic lines all together The ClinVar card prints one line per known deep intronic site, hit or not, a fixed panel. Each line was tagged by its gene, so a closed result in ATM or GLA left one line out of six and named the gene. veil() now tags every such line with one panel tag (FIXED_LINES), and person.js keeps those lines closed while any result to act on is closed and opens them once the last one is opened. The docs say only what the tests prove. --- CHANGELOG.md | 2 +- assets/live/person.js | 9 +++++++- bin/live_results.py | 17 +++++++++----- docs/live-site.md | 4 ++-- tests/test_live_demo.py | 45 ++++++++++++++++++++++++++++++-------- tests/test_live_results.py | 22 +++++++++++++------ 6 files changed, 74 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b842bb3d3..ee46ec17c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Each release's notes. From 1.0.0 on, a major or minor release has hand-written n ## Unreleased - The family page now keeps a child's results to act on closed, as the child's own page does, and what it shows of a child no longer depends on which result is closed or where it lies. While a child has a result still closed, or the page cannot read the child's results, these close for that child, one box each: the painting's grandparents (every stretch drawn from its parent alone), the chromosome map image, all de novo and compound-het rows, the child's medicines and HLA cells in the side by side, and the carrier screen of a couple the child is part of. Each "Show the result" opens one result; the places come back once the last one is opened, and a reload closes them again. While the results cannot be read, each box opens only its own place. On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too, on a phone in the card's line as well. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: `results.json` and `family.json` hold every row. -- A child's page no longer tells which result to act on is closed. A closed finding's card, its place in the page and the address carry a handle (`#f/closed-2`) instead of its id, which names the gene and variant; a link to `#f/` lands on the closed card with the handle in the address, and the id comes back once the result is opened. A closed structural variant now closes the rows and findings that name any of its genes, not only the first. The cards whose rows are the same genes for everyone (known mobile element insertions, repeat expansions, VNtyper, Lp(a), CHIP, the copy numbers, CPIC, CYP2D6, HLA, Locityper, the traits panel, the blood groups) close whole while any result is closed, behind one box, and a card's note on its closed rows no longer counts them. `results.json` gains `veil.panels`, a `genes` list on each structural-variant finding, and one `veil.spans` row per gene; nothing is renamed or removed. +- A child's page no longer tells which result to act on is closed. A closed finding's card, its place in the page and the address carry a handle (`#f/closed-2`) instead of its id, which names the gene and variant; a link to `#f/` lands on the closed card with the handle in the address, and the id comes back once the result is opened. A closed structural variant now closes the rows and findings that name any of its genes, not only the first. The cards whose rows are the same genes for everyone (known mobile element insertions, repeat expansions, VNtyper, Lp(a), CHIP, the copy numbers, CPIC, CYP2D6, HLA, Locityper, the traits panel, the blood groups) close whole while any result is closed, behind one box, and so do the ClinVar card's lines for the known deep intronic sites, all together; a card's note on its closed rows no longer counts them. `results.json` gains `veil.panels`, the tag `*panel*` in `veil.lines`, a `genes` list on each structural-variant finding, and one `veil.spans` row per gene; nothing is renamed or removed. - Step 40, the 23andMe and AncestryDNA raw-data files, now runs on the Rust `og` binary: `og consumer export`, `og consumer build-sites` and `og consumer check` replace `bin/export_consumer.py`. On a synthetic gVCF of 552 MB (32 million records) with 617,044 sites it takes about 2 s and 43 MB in a Linux container on 4 cores, where the Python took 30 s and 660 MB; the files hold the same rows. `setup.sh --consumer-sites` reads dbSNP on 4 cores without the grep pass. The zips are now the same bytes for the same day (`--date`, else `SOURCE_DATE_EPOCH`, else today). Fixes: a call of three or more alleles stops the export with the site, instead of a file the strict reader refuses; two dbSNP records of one rsID at one position have their ALT alleles joined before the strand test; a bad site row or an unreadable zip is reported with the file and line instead of a traceback; a good export removes the `.txt` files a failed one left. The musl builds of `og` use the mimalloc allocator, which keeps a long multi-threaded read at its live memory. Step 40 needs `og`: `scripts/setup.sh ` builds it, and `scripts/setup.sh --og` builds only it. - Steps 21, 32 and 9b (and the CYRIUS, PYPGX and STRANGER_RFC1 modules) now run the CYP2D6 depth check and the RFC1 motif count with `og`, and `bin/cyp2d6_depth_check.py` and `bin/rfc1_motif.py` are gone. A run that reaches these steps needs the binary: run `./scripts/setup.sh --og` once after setup and after each pull; without it the step stops with `og is not built`. The output files, their names and formats do not change; in CI, every e2e case that runs these steps in bash checks `og` against the deleted Python, byte for byte. diff --git a/assets/live/person.js b/assets/live/person.js index 6e7cbb039..bbd82b4fb 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -325,7 +325,14 @@ // A row outside its result's card (a ClinVar row, a card's line, a viewer link, a chart's mark, a search hit) // carries the id of the result to act on it names (results.veil, bin/live_results.py veil()): it stays closed // while that result is, and "Show the result" opens it with it. A tag naming no finding of these results stays closed. - function tagClosed(id) { var f = findingById(id); return closedPage() && (!f || veiled(f)); } + // A line of a fixed panel (PANEL, bin/live_results.py FIXED_LINES: a known deep intronic site) names no result: + // it stays closed while any result to act on is, whichever, so what is missing never tells the gene. + var PANEL = '*panel*'; + function tagClosed(id) { + if (id === PANEL) { return closedPage() && closedActs().length > 0; } + var f = findingById(id); + return closedPage() && (!f || veiled(f)); + } function veilTags(kind, key) { return ((results && results.veil && results.veil[kind]) || {})[key] || []; } // The charts (plugins/charts.js) ask for each mark. A closed page with no results.json yet cannot tell which // marks those are, so it leaves every mark out until it can. diff --git a/bin/live_results.py b/bin/live_results.py index 80c3954fb..c6beb862f 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -179,7 +179,7 @@ def a_clinvar(S, s): lines = [x for x in (rr.cdna_scan_lines(d) or [])] for x in d.get("deep_intronic") or []: # Always reported by the report, hit or not: each site's call stays visible on the card. - lines.append(f"Known deep intronic site (always reported): {x.get('gene')} {x.get('hgvs')} " + lines.append(f"{FIXED_LINES['clinvar']} {x.get('gene')} {x.get('hgvs')} " f"{x.get('chrom')}:{x.get('pos')} {x.get('ref')}>{x.get('alt')}: {rr.deep_intronic_call(x)}") freq = rr.clinvar_freq_line(d) if freq: @@ -1166,6 +1166,12 @@ def lead(found, levels, summary): "traits-panel": "the trait panel's genes", "bloodtype": "the blood group systems", } +# The lines of a card that are a fixed panel inside it (card -> the words each such line starts with): the ClinVar +# card prints one line per known deep intronic site of assets/deep_intronic_sites.tsv, hit or not. veil() tags +# each of them PANEL, never by its gene, so they close all together while any result to act on is closed and +# open all together once the last one is opened (assets/live/person.js tagClosed()). +FIXED_LINES = {"clinvar": "Known deep intronic site (always reported):"} +PANEL = "*panel*" LOCUS_RE = re.compile(r"(? one id or None per row; the ClinVar card's by each hit's own position), each card's viewer - links (viewer: card -> one per link), each card's lines (lines: card -> one per line; a known deep - intronic site is printed whether hit or not), the marks of the report's charts (marks: chart -> {mark id: - id}), every other finding that names a closed result (findings: finding id -> id; a symptom ranking can + links (viewer: card -> one per link), each card's lines (lines: card -> one per line; a line of a fixed + panel, such as a known deep intronic site, printed whether hit or not, is tagged PANEL: FIXED_LINES), the + marks of the report's charts (marks: chart -> {mark id: id}), every other finding that names a closed result (findings: finding id -> id; a symptom ranking can rank the gene) and the lead's segments that name one (lead: one per segment of LEAD_PARTS, the "act" names left out: the page closes those itself), each card's facts (facts: card -> one per fact) and the items of its drawing (widget: card -> one per CHIP call or copy-number dot), and the cards of a fixed panel @@ -1271,7 +1277,8 @@ def veil(summary, found, chapters, lead_parts=()): tags = [(rt[i] if i < len(rt) else None) or t for i, t in enumerate(tags)] if any(tags): out["viewer"][c["id"]] = tags - tags = [veil_id(spans, ln) for ln in c.get("lines") or []] + fixed = FIXED_LINES.get(c["id"]) + tags = [PANEL if fixed and str(ln).startswith(fixed) else veil_id(spans, ln) for ln in c.get("lines") or []] if any(tags): out["lines"][c["id"]] = tags tags = [veil_id(spans, " ".join(str(x) for x in f)) for f in c.get("facts") or []] diff --git a/docs/live-site.md b/docs/live-site.md index 0800efa7d..1315b98d2 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -56,9 +56,9 @@ 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 its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows, lines and facts, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A structural variant names every gene it covers, so a row that names any of them closes with it. A card with such rows says that some of its rows stay closed, in the same words however many there are, and opening the result opens them with it. The pages differ only in what is open and in how many results are closed, never in which result is closed: +A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows, lines and facts, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A structural variant names every gene it covers, so a row that names any of them closes with it. A card with such rows says that some of its rows stay closed, in the same words however many there are, and opening the result opens them with it. Two pages whose open rows are the same differ only in how many results are closed, not in which; the section that holds a closed result still says it holds one: -- A card whose rows are a fixed panel, the same genes or loci for everyone, would tell the closed result's gene by the row it leaves out. So these cards close whole while any result to act on is closed, behind one box with the same words whatever the result: the known mobile element insertions, the repeat expansions, VNtyper, Lp(a), CHIP, the paralog copy numbers (SMN1, SMN2, HBA1, HBA2, the RCCX module), CPIC, CYP2D6, HLA, Locityper, the traits panel and the blood groups. The one list is `FIXED_PANELS` in `bin/live_results.py`, and `results.json` gives it as `veil.panels`. Their "Show the result" opens the next closed result, and the cards come back once every result is opened. The cost: a child's page with a closed result shows none of these cards until the results are opened. Their own findings (a medicine gene, a CHIP call) still show in their sections. +- A card whose rows are a fixed panel, the same genes or loci for everyone, would tell the closed result's gene by the row it leaves out. So these cards close whole while any result to act on is closed, behind one box with the same words whatever the result: the known mobile element insertions, the repeat expansions, VNtyper, Lp(a), CHIP, the paralog copy numbers (SMN1, SMN2, HBA1, HBA2, the RCCX module), CPIC, CYP2D6, HLA, Locityper, the traits panel and the blood groups. The one list is `FIXED_PANELS` in `bin/live_results.py`, and `results.json` gives it as `veil.panels`. Their "Show the result" opens the next closed result, and the cards come back once every result is opened. The cost: a child's page with a closed result shows none of these cards until the results are opened. Their own findings (a medicine gene, a CHIP call) still show in their sections. A card can hold a fixed panel among its lines: the ClinVar card prints a line for each known deep intronic site, hit or not. Those lines close all together while any result to act on is closed and come back once every result is opened (`FIXED_LINES` in `bin/live_results.py`; `veil.lines` tags each of them `*panel*`). - A closed finding carries no id in the page or the address, since its id names its gene and variant. It is known by its place among the page's closed findings (`#f/closed-2`). A link to `#f/` of a closed result lands on its card, and the address becomes that handle. The id comes back once the result is opened, and goes again when it is closed. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens one result per click, the next closed result in the page's order (the order of its closed cards), so the order does not tell either whether a closed result has a place. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A result with no place on the map opens in its section only: the map draws nothing new, and a chip reads "Opened in its section:" with the result's title, a link to its card and a button to close it again. That chip names only a result that is open, and it goes when the result closes. After Open the keyboard moves to "Show the result", and after "Show the result" or "Keep it closed" to the chips' first button, or to the map when no chip is left. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. While the traits card is closed whole on a child's page, the chip is the switch. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 487fa3d2e..5f2a8ffcb 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -1479,6 +1479,23 @@ def change(S): return change +def deep_sites(S): + """The ClinVar card's line for each known deep intronic site of assets/deep_intronic_sites.tsv, reference calls: + a fixed panel the son's demo summary leaves out.""" + with open(os.path.join(REPO, "assets", "deep_intronic_sites.tsv")) as fh: + rows = [ln.rstrip("\n").split("\t") for ln in fh if ln.strip() and not ln.startswith("#")] + S["clinvar"]["data"]["deep_intronic"] = [dict(zip(rows[0], r), call="ref", detail="GQ 50") for r in rows[1:]] + return len(rows) - 1 + + +def with_sites(gene): + """cpsr(GENE) with the known deep intronic sites' lines (deep_sites), the same on both pages.""" + def change(S): + cpsr(gene)(S) + deep_sites(S) + return change + + def sv_over(locus, genes): """A class 5 deletion over GENES, and a carrier row that names only its second gene.""" def change(S): @@ -1505,6 +1522,8 @@ def second_hit(S): ("SMN1 against HBA1, the same copy numbers", cpsr("SMN1"), cpsr("HBA1")), ("a result with a place on the map (BRCA2) against one without (MLH1)", cpsr("BRCA2"), cpsr("MLH1")), ("a closed result with one ClinVar row against two", lambda S: None, second_hit), + ("a closed result in a gene of a known deep intronic site (ATM) against one in none (BRCA2)", with_sites("ATM"), + with_sites("BRCA2")), ("a link to each of two closed results (#f/)", cpsr("BRCA1"), cpsr("BRCA2"), ("#f/cpsr-brca1-c-68-69del", "#f/cpsr-brca2-c-68-69del"))) @@ -1588,8 +1607,9 @@ def infer_checks(b, base, out): def widget_veil_checks(b, base, out): """On the son's page the copy-number card and the CHIP card, whose rows are a fixed panel (the same genes for everyone), close whole while any result of his to act on is closed: no dot, no call, no fact of theirs shows, - and their line on a phone is the same words whatever the result. Each "Show the result" of their box opens - one result; once every result is opened every dot, call and fact is back.""" + and their line on a phone is the same words whatever the result; so do the ClinVar card's lines for the known + deep intronic sites, a fixed panel inside it. Each "Show the result" of their box opens one result; once every + result is opened every dot, call, fact and deep intronic line is back.""" no_cache(b) rj = os.path.join(out, "runs", OTHERS[1], "results.json") js = os.path.join(out, "assets", "person.js") @@ -1632,10 +1652,14 @@ def shown(): def problems(text, opened): page, cops, _ = json.loads(text) if text else ["", "", 0] out_ = [] - if opened and not ("SMN1" in cops and "SMN2" in cops and "p.R175H" in page and "SMN1 copies" in page): - out_.append(f"every result opened, yet a dot, the call or the fact is missing: {cops[:200]}") - if not opened and (cops or "p.R175H" in page or "SMN1 copies" in page): - out_.append(f"a closed result, yet the copy-number or CHIP card shows its rows: {cops[:200]}") + deep = page.count(live_results.FIXED_LINES["clinvar"]) + if opened and not ("SMN1" in cops and "SMN2" in cops and "p.R175H" in page and "SMN1 copies" in page + and deep == sites[0]): + out_.append(f"every result opened, yet a dot, the call, the fact or a deep intronic line ({deep} of {sites[0]}) is " + f"missing: {cops[:200]}") + if not opened and (cops or "p.R175H" in page or "SMN1 copies" in page or deep): + out_.append(f"a closed result, yet the copy-number or CHIP card shows its rows or the ClinVar card " + f"{deep} deep intronic lines: {cops[:200]}") return out_ try: with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: @@ -1648,6 +1672,7 @@ def problems(text, opened): {"gene": g, "protein_change": ch, "vaf": v, "alt_reads": 6, "depth": 75, "status": "pass", "note": "a clone"} for g, ch, v in (("DNMT3A", "p.R882H", 0.12), ("TP53", "p.R175H", 0.08))], "genes": [{"gene": g, "detection_limit_vaf": 0.05, "mean_depth": 75, "status": "ok"} for g in ("DNMT3A", "TP53")]}} + sites = [deep_sites(S)] S["paralogs"] = {"state": "ok", "data": {"genes": [{"gene": g, "copy_number": n, "status": "called", "detail": "."} for g, n in (("SMN1", 2), ("SMN2", 2))]}} res = live_results.build(OTHERS[1], summary) @@ -1666,12 +1691,14 @@ def problems(text, opened): break bad += problems(shown(), True) check("browser: on the son's page the copy-number and CHIP cards, a fixed panel, close whole while a result is " - "closed (their line on a phone too), their box opens one result per click, and every dot, call and fact is " - "back once every result is opened", not bad, bad) + "closed (their line on a phone too), and so do the ClinVar card's deep intronic lines; the box opens one " + "result per click, and every dot, call, fact and line is back once every result is opened", not bad, bad) for desc, a, z in ( ("person.js that draws a fixed-panel card's open rows", "} else if (panelShut(c)) {", "} else if (false) {"), ("person.js whose card line reads a fixed-panel card's facts", ": g === 'show' && panelShut(c) ? PANEL_LINE", - ": false ? PANEL_LINE")): + ": false ? PANEL_LINE"), + ("person.js that draws a fixed panel's lines", "if (id === PANEL) { return closedPage() && closedActs().length > 0; }", + "if (id === PANEL) { return false; }")): if a not in keep_js: check(f"control: {desc} (the planted line is in person.js)", False, a) continue diff --git a/tests/test_live_results.py b/tests/test_live_results.py index 8faefe11f..321525727 100644 --- a/tests/test_live_results.py +++ b/tests/test_live_results.py @@ -36,9 +36,10 @@ link of the planted hit, its genome-chart tick and its review-star bar carry the secondary finding's id, the deletion's span carries its own, the search index's items that name the gene carry one of them, and nothing else is - tagged; DEMO-001, with no result to act on, has no tag; and a known deep - intronic site's line in the gene of a closed secondary finding carries - that finding's id (the card prints each site whether hit or not), a gene + tagged; DEMO-001, with no result to act on, has no tag; and every known + deep intronic site's line carries the fixed-panel tag, never the id of a + closed result in its gene (the card prints each site whether hit or not, + so one missing line would name the gene), a gene named before a full stop is that gene, and a ClinVar row closed by its gene alone closes its "See the reads at" link with it; every other card's row that names the gene (the son's symptom ranking ranks it) carries one of @@ -404,7 +405,8 @@ def veil_problems(res, idx): def veil_lines_problems(): """What is wrong with the veil's tags of the ClinVar card's lines: a known deep intronic site is printed - whether hit or not, so its line names the gene of a closed secondary finding in that gene.""" + whether hit or not, a fixed panel, so every such line carries the panel tag (live_results.PANEL), the one in + a closed secondary finding's gene and the others alike: a line closed alone would name the gene.""" S = {"clinvar": {"state": "ok", "data": {"hits": [], "deep_intronic": [ {"gene": "GLA", "hgvs": "NM_000169.3:c.640-801G>A", "chrom": "chrX", "pos": 101399747, "ref": "C", "alt": "T", "call": "hom", "detail": "GQ 40"}, @@ -413,9 +415,9 @@ def veil_lines_problems(): lines = live_results.a_clinvar(S, {"sections": S}).get("lines") or [] found = [{"id": "sf-gla-test", "level": "act", "gene": "GLA", "detail": "GLA"}] v = live_results.veil({"sections": S}, found, [{"sections": [{"id": "clinvar", "lines": lines}]}]) - want = ["sf-gla-test" if "GLA " in ln else None for ln in lines] + want = [live_results.PANEL if ln.startswith(live_results.FIXED_LINES["clinvar"]) else None for ln in lines] got = (v.get("lines") or {}).get("clinvar") - out = [] if "sf-gla-test" in want and None in want and got == want else [f"lines {lines} tagged {got}, want {want}"] + out = [] if want.count(live_results.PANEL) == 2 and got == want else [f"lines {lines} tagged {got}, want {want}"] # A gene named at the end of a sentence ("in GLA.") is that gene. if live_results.veil_id(live_results.closable(found), "A change in GLA.") != "sf-gla-test": out.append("'A change in GLA.' carries no tag") @@ -612,9 +614,15 @@ def tagged(sample_dir): r3["_summary"] = s3 return r3, results_index.build(s3, sample_dir, rr.SECTION_OF) bad = veil_lines_problems() - check("8. a known deep intronic site's line in a closed secondary finding's gene carries its id, a gene " + check("8. every known deep intronic site's line carries the fixed-panel tag, not a closed result's id, a gene " "before a full stop is that gene, a ClinVar row closed by its gene closes its reads link, and a lead " "segment naming the gene carries its id", not bad, bad) + fixed = live_results.FIXED_LINES + try: # the card still prints its lines; veil() no longer knows they are a panel + live_results.FIXED_LINES = type("Unknown", (dict,), {"get": lambda self, k, d=None: None})(fixed) + control("a veil that tags a known deep intronic site's line by its gene", not veil_lines_problems()) + finally: + live_results.FIXED_LINES = fixed bad = overview_problems() check("8. a child's home tile lists no finding that names a closed result, and its closed lead names none", not bad, bad) From 1edf2e9049ac21efe6e040e1c2c1aff9bb6ce033 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:02:05 +0100 Subject: [PATCH 26/27] fix(live): close a child's fixed panels, guessed ids and history entries the same whatever result is closed A fixed-panel card's own findings (a medicine gene, a CHIP call) and the lead's words on them now carry the panel tag, so a closed result in CYP2D6 no longer turns the medicine card's box into "This section holds a result" or moves its finding among the open ones. The carrier list's two-hit line and the callability card's rows join the fixed lines (FIXED_LINES is now a card to list-of-prefixes table). A row closes with a result only when it names the gene as a gene symbol: prose words, ontology ids and units no longer match, and the score card is never tagged. The address never holds a closable finding's id: #f/opened- once opened, and any #f/ that is not an open finding lands on the first closed one, so a guessed id tells nothing. The home page links a child's closed result to the report, not to its own page, and the finding page treats panel findings as the person page does. closedActs() checks the level before the veil, which a panel-tagged finding would otherwise recurse through. --- CHANGELOG.md | 2 +- assets/live/finding.js | 13 ++- assets/live/home.js | 3 +- assets/live/map.js | 5 +- assets/live/person.js | 50 ++++++--- bin/live_results.py | 92 ++++++++++++---- docs/live-site.md | 4 +- tests/test_live_demo.py | 213 ++++++++++++++++++++++++++++++++++--- tests/test_live_results.py | 89 +++++++++++++++- 9 files changed, 416 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee46ec17c..cb03678de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Each release's notes. From 1.0.0 on, a major or minor release has hand-written n ## Unreleased - The family page now keeps a child's results to act on closed, as the child's own page does, and what it shows of a child no longer depends on which result is closed or where it lies. While a child has a result still closed, or the page cannot read the child's results, these close for that child, one box each: the painting's grandparents (every stretch drawn from its parent alone), the chromosome map image, all de novo and compound-het rows, the child's medicines and HLA cells in the side by side, and the carrier screen of a couple the child is part of. Each "Show the result" opens one result; the places come back once the last one is opened, and a reload closes them again. While the results cannot be read, each box opens only its own place. On a child's page, a copy-number dot and a card's fact that name a closed result's gene now close with it too, on a phone in the card's line as well. `results.json` adds `veil.spans`, `veil.facts` and `veil.widget`; nothing is renamed or removed. The veil is still a browser veil: `results.json` and `family.json` hold every row. -- A child's page no longer tells which result to act on is closed. A closed finding's card, its place in the page and the address carry a handle (`#f/closed-2`) instead of its id, which names the gene and variant; a link to `#f/` lands on the closed card with the handle in the address, and the id comes back once the result is opened. A closed structural variant now closes the rows and findings that name any of its genes, not only the first. The cards whose rows are the same genes for everyone (known mobile element insertions, repeat expansions, VNtyper, Lp(a), CHIP, the copy numbers, CPIC, CYP2D6, HLA, Locityper, the traits panel, the blood groups) close whole while any result is closed, behind one box, and so do the ClinVar card's lines for the known deep intronic sites, all together; a card's note on its closed rows no longer counts them. `results.json` gains `veil.panels`, the tag `*panel*` in `veil.lines`, a `genes` list on each structural-variant finding, and one `veil.spans` row per gene; nothing is renamed or removed. +- A child's page no longer tells which result to act on is closed. A closed finding's card, its place in the page and the address carry a handle (`#f/closed-2`) instead of its id, which names the gene and variant; the address never holds a closable finding's id, closed (`#f/closed-`) or opened (`#f/opened-`), so no history entry keeps it, and any other `#f/` on such a page, a closed result's id or nothing, lands on the first closed finding, so a guessed id tells nothing. The home page links a child's closed result to the child's report, not to the finding's own page. A closed structural variant now closes the rows and findings that name any of its genes, not only the first. The cards whose rows are the same genes for everyone (known mobile element insertions, repeat expansions, VNtyper, Lp(a), CHIP, the copy numbers, CPIC, CYP2D6, HLA, Locityper, the traits panel, the blood groups) close whole while any result is closed, behind one box, and so do their own findings (a medicine gene, a CHIP call) and the lead's words on them, the ClinVar card's lines for the known deep intronic sites, the carrier list's two-hit line and the callability card's rows, all together; a row or line closes with a result only when it names its gene as a gene symbol (never a prose word, an ontology id or a unit such as `5 MB`), and the polygenic score card is never tagged; a card's note on its closed rows no longer counts them. `results.json` gains `veil.panels`, the tag `*panel*` in `veil.lines`, `veil.rows`, `veil.findings` and `veil.lead`, a `genes` list on each structural-variant finding, and one `veil.spans` row per gene; nothing is renamed or removed. - Step 40, the 23andMe and AncestryDNA raw-data files, now runs on the Rust `og` binary: `og consumer export`, `og consumer build-sites` and `og consumer check` replace `bin/export_consumer.py`. On a synthetic gVCF of 552 MB (32 million records) with 617,044 sites it takes about 2 s and 43 MB in a Linux container on 4 cores, where the Python took 30 s and 660 MB; the files hold the same rows. `setup.sh --consumer-sites` reads dbSNP on 4 cores without the grep pass. The zips are now the same bytes for the same day (`--date`, else `SOURCE_DATE_EPOCH`, else today). Fixes: a call of three or more alleles stops the export with the site, instead of a file the strict reader refuses; two dbSNP records of one rsID at one position have their ALT alleles joined before the strand test; a bad site row or an unreadable zip is reported with the file and line instead of a traceback; a good export removes the `.txt` files a failed one left. The musl builds of `og` use the mimalloc allocator, which keeps a long multi-threaded read at its live memory. Step 40 needs `og`: `scripts/setup.sh ` builds it, and `scripts/setup.sh --og` builds only it. - Steps 21, 32 and 9b (and the CYRIUS, PYPGX and STRANGER_RFC1 modules) now run the CYP2D6 depth check and the RFC1 motif count with `og`, and `bin/cyp2d6_depth_check.py` and `bin/rfc1_motif.py` are gone. A run that reaches these steps needs the binary: run `./scripts/setup.sh --og` once after setup and after each pull; without it the step stops with `og is not built`. The output files, their names and formats do not change; in CI, every e2e case that runs these steps in bash checks `og` against the deleted Python, byte for byte. diff --git a/assets/live/finding.js b/assets/live/finding.js index 5f6fc3a31..fbcdee3c8 100644 --- a/assets/live/finding.js +++ b/assets/live/finding.js @@ -83,9 +83,18 @@ var tags = (r.veil && r.veil.findings) || {}; function tagOf(x) { var t = tags[x.id]; return t && t !== x.id ? t : null; } // Opening this finding opens it and the result it names, nothing else: another result keeps its own veil. + // A finding of a fixed-panel card (tag PANEL, bin/live_results.py FIXED_PANELS) names no result: it stays closed + // while any result to act on is, whichever, as on the person page. + var PANEL = '*panel*'; var opened = {}; - if (draw.open) { opened[f.id] = true; if (tagOf(f)) { opened[tagOf(f)] = true; } } - function shut(x) { return child && (x.level === 'act' ? !opened[x.id] : !!tagOf(x) && !opened[tagOf(x)]); } + if (draw.open) { opened[f.id] = true; if (tagOf(f) && tagOf(f) !== PANEL) { opened[tagOf(f)] = true; } } + function actShut() { return (r.findings || []).some(function (x) { return x.level === 'act' && !opened[x.id]; }); } + function shut(x) { + if (!child) { return false; } + if (x.level === 'act') { return !opened[x.id]; } + var t = tagOf(x); + return t === PANEL ? !opened[x.id] && actShut() : !!t && !opened[t]; + } // Closed: no title, gene or variant anywhere on the page (its title, crumb, cover, rail) until opened. var closed = shut(f); var title = !closed ? f.title : f.level === 'act' ? 'A result to act on' : 'A finding about a result to act on'; diff --git a/assets/live/home.js b/assets/live/home.js index 6f25e7bb6..2afb54fa9 100644 --- a/assets/live/home.js +++ b/assets/live/home.js @@ -192,7 +192,8 @@ }); 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) + '/' }); + // A child's closed result links to the run's page, never to its own page: its id names its gene and variant. + var a = el('a', { href: x.r.url + (closedRuns[x.r.name] && x.f.level === 'act' ? '' : '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]); } diff --git a/assets/live/map.js b/assets/live/map.js index f19b2d39d..860382ebe 100644 --- a/assets/live/map.js +++ b/assets/live/map.js @@ -106,7 +106,8 @@ // --- the figure --------------------------------------------------------------------------------------- // create({url, veil(id, gated) -> 'open'|'closed'|'opened', now(id) -> shown at this moment, open(id), // close(id), known(id) -> a finding of the page's results, closed() -> the ids of the results to act on the - // page keeps closed at this moment, stepDone(step) -> ended at this moment, fallback() -> rows}): one figure + // page keeps closed at this moment, stepDone(step) -> ended at this moment, href(id) -> the address of a finding + // the page opened (never its id on a child's page), fallback() -> rows}): one figure // per page. The page calls refresh(key) on each of its redraws; a new key // fetches the document again and keeps the camera. function create(opts) { @@ -910,7 +911,7 @@ inSection = inSection.filter(function (id) { return stateOf(id) !== 'closed' && opts.title && opts.title(id); }); inSection.forEach(function (id) { var c = el('div', { 'class': 'gm-chip opened', 'data-opened': 'section' }); - add(c, add(el('span', {}, 'Opened in its section: '), el('a', { href: '#f/' + id }, opts.title(id)))); + add(c, add(el('span', {}, 'Opened in its section: '), el('a', { href: '#f/' + (opts.href ? opts.href(id) : id) }, opts.title(id)))); add(c, button('Close it again', function () { confirming = null; opts.close(id); keyTo(); })); add(chips, c); }); diff --git a/assets/live/person.js b/assets/live/person.js index bbd82b4fb..ddfbdbbf8 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -370,7 +370,7 @@ } function veiledOf(c) { return (c.findings || []).map(findingById).filter(function (f) { return f && veiled(f); }); } // The results to act on this page keeps closed, drawn yet or not. - function closedActs() { return ((results && results.findings) || []).filter(function (f) { return veiled(f) && f.level === 'act'; }); } + function closedActs() { return ((results && results.findings) || []).filter(function (f) { return f.level === 'act' && veiled(f); }); } // A card of a fixed panel (results.veil.panels, bin/live_results.py FIXED_PANELS: the same genes or loci for // everyone, known in advance): a row or dot of it closed with a result would tell the result's gene by what is // missing. So it closes whole while this page keeps any result to act on closed, behind one box with the same @@ -447,14 +447,15 @@ 'Step ' + (c.steps || []).join(', ') + ' failed: the step timeline says why, and a rerun of the step fills this section.')); } else if (g === 'empty') { add(text, el('p', { 'class': 'notassessed' }, 'Not assessed: its step ended without a result for this section.')); + } else if (panelShut(c)) { + // A fixed-panel card says the same words whichever result is closed, its own findings' too: so it comes first. + // One button opens one result, the next closed one in the page's order, as the map's Open does. + add(text, U.veil(PANEL_WORDS, null, function () { var f = nextClosed(); if (f) { openFinding(f); } redraw(true); })); } else if (closedFs.length) { // One button opens one result: a card with two results to act on asks twice. add(text, U.veil((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.', null, function () { openFinding(closedFs[0]); redraw(true); var x = document.getElementById('f-' + closedFs[0].id) || document.getElementById('card-' + c.id); if (x) { x.scrollIntoView({ block: 'start' }); } })); - } else if (panelShut(c)) { - // One button opens one result, the next closed one in the page's order, as the map's Open does. - add(text, U.veil(PANEL_WORDS, null, function () { var f = nextClosed(); if (f) { openFinding(f); } redraw(true); })); } else { (c.notes || []).forEach(function (n) { add(text, el('p', { 'class': 'note-stale' }, n)); }); if (c.headline) { add(text, el('p', { 'class': 'headline' }, c.headline)); } @@ -578,26 +579,45 @@ var t = findingTag(f); return !!(t && tagClosed(t)); } - function openFinding(f) { keepHash(function () { shown[f.id] = true; var t = findingTag(f); if (t) { shown[t] = true; } }); } - // The address never keeps a closed finding's id. #f/ of a finding this page keeps closed (a link from another - // page, or a finding closed again since) becomes #f/closed-, its handle, and #f/ again once it is opened. + // A finding of a fixed-panel card (tag PANEL) stays closed while any result to act on is: opening it opens the next + // closed result in the page's order, as its card's box does. + function openFinding(f) { + var t = findingTag(f), n = t === PANEL ? nextClosed() : null; + keepHash(function () { shown[f.id] = true; if (n) { shown[n.id] = true; } else if (t && t !== PANEL) { shown[t] = true; } }); + } + // The address never holds the id of a finding this page can close, closed or opened: #f/closed- while it is + // closed (its handle) and #f/opened- once opened on purpose (its place among the opened ones), so no history + // entry keeps the id. Any other #f/ on a page that keeps a result closed, a closed result's id or no id at all, + // lands the same way, on #f/closed-1: the page never picks a closed card by its id, so a guessed id tells nothing. // Opening or closing a result moves the handles of the others: the address follows the finding it named. The // finding behind a handle in the address is kept here only (hashId); a new address from the reader forgets it. var hashId = null; window.addEventListener('hashchange', function () { hashId = null; }); + function closable(f) { return closedPage() && (f.level === 'act' || !!findingTag(f)); } + function openedOrder() { + var out = []; + sectionsList().forEach(function (sec) { + (sec.findings || []).forEach(function (id) { var f = findingById(id); if (f && closable(f) && !veiled(f) && out.indexOf(id) < 0) { out.push(id); } }); + }); + ((results && results.findings) || []).forEach(function (f) { if (closable(f) && !veiled(f) && out.indexOf(f.id) < 0) { out.push(f.id); } }); + return out; + } + function addr(f) { return !closable(f) ? f.id : veiled(f) ? handle(f) : 'opened-' + (openedOrder().indexOf(f.id) + 1); } function hashFinding() { var h; try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return null; } if (h.indexOf('f/') !== 0) { return null; } - var x = h.slice(2); - if (/^closed-\d+$/.test(x)) { return (hashId && findingById(hashId)) || findingById(closedOrder()[Number(x.slice(7)) - 1]) || null; } - return findingById(x) || null; + var x = h.slice(2), m = /^(closed|opened)-(\d+)$/.exec(x); + var f = (m && hashId && findingById(hashId)) || + (m ? findingById((m[1] === 'closed' ? closedOrder() : openedOrder())[Number(m[2]) - 1]) : findingById(x)); + if (f && !(veiled(f) && !m)) { return f; } + return closedOrder().length ? findingById(closedOrder()[0]) : null; } function syncHash() { var f = hashFinding(); if (!f || !window.history || !window.history.replaceState) { return; } hashId = f.id; - var to = '#f/' + handle(f); + var to = '#f/' + addr(f); if (window.location.hash !== to) { window.history.replaceState(null, '', to); } } function keepHash(change) { var at = hashFinding(); if (at) { hashId = at.id; } change(); syncHash(); } @@ -639,6 +659,8 @@ return findingsNow().filter(function (f) { return f.level === 'act' && veiled(f); }).map(function (f) { return f.id; }); } // The title of a result once the page shows it, for the map's "Opened in its section" chip; never a closed one's. + // The address of a result the map opened, for its "Opened in its section" link: never its id (addr()). + function mapHref(id) { var f = findingById(id); return f ? addr(f) : id; } function mapTitle(id) { var f = findingById(id); return f && !veiled(f) ? f.title : ''; } // Whether a row of the map is a finding of these results: then the page's own veil alone decides it (map.js). function mapKnown(id) { return !!findingById(id); } @@ -682,7 +704,7 @@ 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)); }); + rel.slice(0, 4).forEach(function (r, i) { if (i) { add(box, el('br', {})); } add(box, el('a', { href: '#f/' + addr(r) }, r.title)); }); add(side, note('Linked', box, 'linked-note')); } add(row, art, side); @@ -955,7 +977,7 @@ 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.')); } - openProblems.forEach(function (f) { add(trust, el('span', { 'class': 'sep' }, '·'), el('a', { href: '#f/' + f.id }, f.title)); }); + openProblems.forEach(function (f) { add(trust, el('span', { 'class': 'sep' }, '·'), el('a', { href: '#f/' + addr(f) }, f.title)); }); add(trust, el('span', { 'class': 'sep' }, '·'), el('a', { href: '#s5' }, 'Can I trust this?')); add(main, trust); var side = el('aside', {}); @@ -967,7 +989,7 @@ // reader left it. It asks this page's veil for each closed row, and opens or closes a result through it. if (window.PGPMap) { if (!genomeMap) { - genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, known: mapKnown, closed: mapClosed, title: mapTitle, stepDone: stepDone, open: function (id) { setShown(id, true); }, + genomeMap = window.PGPMap.create({ url: 'map.json', veil: mapVeil, now: mapNow, known: mapKnown, closed: mapClosed, title: mapTitle, href: mapHref, stepDone: stepDone, open: function (id) { setShown(id, true); }, close: function (id) { setShown(id, false); }, fallback: mapFallback }); } add(slot, genomeMap.node); diff --git a/bin/live_results.py b/bin/live_results.py index c6beb862f..65c542fe9 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -179,7 +179,7 @@ def a_clinvar(S, s): lines = [x for x in (rr.cdna_scan_lines(d) or [])] for x in d.get("deep_intronic") or []: # Always reported by the report, hit or not: each site's call stays visible on the card. - lines.append(f"{FIXED_LINES['clinvar']} {x.get('gene')} {x.get('hgvs')} " + lines.append(f"{FIXED_LINES['clinvar'][0]} {x.get('gene')} {x.get('hgvs')} " f"{x.get('chrom')}:{x.get('pos')} {x.get('ref')}>{x.get('alt')}: {rr.deep_intronic_call(x)}") freq = rr.clinvar_freq_line(d) if freq: @@ -1166,14 +1166,57 @@ def lead(found, levels, summary): "traits-panel": "the trait panel's genes", "bloodtype": "the blood group systems", } -# The lines of a card that are a fixed panel inside it (card -> the words each such line starts with): the ClinVar -# card prints one line per known deep intronic site of assets/deep_intronic_sites.tsv, hit or not. veil() tags -# each of them PANEL, never by its gene, so they close all together while any result to act on is closed and -# open all together once the last one is opened (assets/live/person.js tagClosed()). -FIXED_LINES = {"clinvar": "Known deep intronic site (always reported):"} +# The lines and table rows of a card that are a fixed panel inside it (card -> the words such a line, or a cell of +# such a row, starts with): the ClinVar card prints one line per known deep intronic site of +# assets/deep_intronic_sites.tsv, hit or not; the carrier list prints its two-hit line, which names SMN1, HBA1 and +# HBA2 as the callers it read, whatever was found; the callability card lists the catalog genes this reference does +# not hold, the same for every genome on it, and the hard genes the reads did not cover, which are nearly the same +# for every genome (segmental duplications, collapsed regions). veil() tags each of them PANEL, never by a gene, so they close all +# together while any result to act on is closed and open all together once the last one is opened +# (assets/live/person.js tagClosed()). Every other line or row says something about this genome alone. +FIXED_LINES = {"clinvar": ("Known deep intronic site (always reported):",), + "carrier-list": ("Two hits across callers",), + "callability": ("not on this reference", "not read")} PANEL = "*panel*" +# The cards whose rows, lines and facts name no gene, only things known before the run: veil() never tags them. The +# score card names its scores and their traits (assets/pgs_scores.tsv) and its words "OR 1.57", "HR 1.4", "(TG)" +# are odds and hazard ratios and a trait, though HR and TG are gene symbols too. +NAMES_NO_GENE = {"prs": "the polygenic scores of assets/pgs_scores.tsv"} + + +def panel_finding(f): + """Whether finding F is one of a fixed-panel card's own (a medicine gene, a CHIP call, a repeat), not a result + to act on: veil() tags it PANEL whatever it names.""" + return f.get("level") != "act" and f.get("card") in FIXED_PANELS + + +def fixed_line(card_id, text): + """Whether TEXT, a line of the card CARD_ID or a cell of one of its rows, is one of its fixed lines (FIXED_LINES).""" + return any(str(text).startswith(w) for w in FIXED_LINES.get(card_id) or ()) LOCUS_RE = re.compile(r"(? one per link), each card's lines (lines: card -> one per line; a line of a fixed panel, such as a known deep intronic site, printed whether hit or not, is tagged PANEL: FIXED_LINES), the marks of the report's charts (marks: chart -> {mark id: id}), every other finding that names a closed result (findings: finding id -> id; a symptom ranking can - rank the gene) and the lead's segments that name one (lead: one per segment of LEAD_PARTS, the "act" - names left out: the page closes those itself), each card's facts (facts: card -> one per fact) and the items + rank the gene; PANEL for every finding of a FIXED_PANELS card) and the lead's segments that name one or name a + fixed-panel card's finding (lead: one per segment of LEAD_PARTS, the "act" names left out: the page closes those + itself), each card's facts (facts: card -> one per fact) and the items of its drawing (widget: card -> one per CHIP call or copy-number dot), and the cards of a fixed panel (panels: FIXED_PANELS, which close whole while any result is closed). spans is closable() itself: the family page (assets/live/family-page.js) reads which results are closed from it, and closes every place of the child @@ -1265,9 +1308,12 @@ def veil(summary, found, chapters, lead_parts=()): out["rows"]["clinvar"] = tags for ch in chapters: for c in ch["sections"]: + if c["id"] in NAMES_NO_GENE: + continue rows = (c.get("table") or {}).get("rows") or [] if c["id"] != "clinvar" and rows: - tags = [veil_id(spans, " ".join(str(x) for x in r) if isinstance(r, (list, tuple)) else str(r)) + tags = [PANEL if any(fixed_line(c["id"], x) for x in (r if isinstance(r, (list, tuple)) else [r])) + else veil_id(spans, " ".join(str(x) for x in r) if isinstance(r, (list, tuple)) else str(r)) for r in rows] if any(tags): out["rows"][c["id"]] = tags @@ -1277,8 +1323,7 @@ def veil(summary, found, chapters, lead_parts=()): tags = [(rt[i] if i < len(rt) else None) or t for i, t in enumerate(tags)] if any(tags): out["viewer"][c["id"]] = tags - fixed = FIXED_LINES.get(c["id"]) - tags = [PANEL if fixed and str(ln).startswith(fixed) else veil_id(spans, ln) for ln in c.get("lines") or []] + tags = [PANEL if fixed_line(c["id"], ln) else veil_id(spans, ln) for ln in c.get("lines") or []] if any(tags): out["lines"][c["id"]] = tags tags = [veil_id(spans, " ".join(str(x) for x in f)) for f in c.get("facts") or []] @@ -1303,13 +1348,23 @@ def veil(summary, found, chapters, lead_parts=()): m.get("end")) if fid: out["marks"].setdefault(sp["id"], {})[m["id"]] = fid + # A finding of a fixed-panel card (a medicine gene, a CHIP call, a repeat) is tagged PANEL whatever it names: it + # closes with its card while any result to act on is closed, so the findings left open never tell which gene the + # closed one is in. So is each segment of the lead that names one (its gene or its title). + panel_found = [f for f in found if panel_finding(f)] + panel_spans = [(PANEL, str(g).upper(), None, None, None) for f in panel_found for g in [f.get("gene")] if g] for f in found: if f.get("level") != "act": - fid = veil_id(spans, f"{f.get('gene') or ''} {f.get('detail') or ''} {f.get('title') or ''} " - f"{f.get('viewer') or ''}") + fid = PANEL if f in panel_found else veil_id(spans, f"{f.get('gene') or ''} {f.get('detail') or ''} " + f"{f.get('title') or ''} {f.get('viewer') or ''}") if fid: out["findings"][f["id"]] = fid - tags = [None if len(x) > 2 else veil_id(spans, x[0]) for x in lead_parts or ()] + + def lead_tag(t): + if veil_id(panel_spans, t) or any(f.get("title") and f["title"] in t for f in panel_found): + return PANEL + return veil_id(spans, t) + tags = [None if len(x) > 2 else lead_tag(x[0]) for x in lead_parts or ()] out["lead"] = tags if any(tags) else [] return {k: v for k, v in out.items() if v} @@ -1354,7 +1409,8 @@ def overview(summary): order["trust"] = -1 # a problem with the data comes before any finding read from it # A finding that names a result to act on (veil: a symptom ranking of its gene) is not listed: a child's tile # closes only the results to act on themselves, and the result it names already comes first. - named = (r.get("veil") or {}).get("findings") or {} + # A finding tagged PANEL (a fixed-panel card's own, veil()) names no result: it stays, the same for everyone. + named = {k: v for k, v in ((r.get("veil") or {}).get("findings") or {}).items() if v != PANEL} top = sorted((f for f in r["findings"] if f["level"] != "info" and f["id"] not in named), key=lambda f: order[f["level"]])[:4] tags = (r.get("veil") or {}).get("lead") or [] diff --git a/docs/live-site.md b/docs/live-site.md index 1315b98d2..7d9ea19ac 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -58,8 +58,8 @@ Every result carries one of six levels, with a word, a shape and a colour, so th A card has its rows (the first ones, then a button for all), its own sentences and its charts at reading width, and in a margin beside them its numbers, its caveat (the first sentence, then the rest behind "Full caveat") and its links; below 1100 px the margin goes under the text. A card with more than six lines keeps the ones that say something in view and folds the rest (an allele absent, a call not determined) behind one line that counts them. 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 the detection limits at this depth (hatched below every gene's limit, shaded where some genes see a clone and others do not, and each call named against its own gene's limit), the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots (Parascopy's SMN1 and SMN2 at exon 7 too, from short reads, hollow with its reason when the count is not resolved), 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 genome 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. Anything else on the child's pages that names a closed result's gene or position stays closed with it: another card's rows, lines and facts, a viewer link, a chart's marks and their titles, another finding that names it (a symptom ranking of the gene) on the report, on its own page, in a finding page's next link and in its related list, and the part of the lead that names it. A structural variant names every gene it covers, so a row that names any of them closes with it. A card with such rows says that some of its rows stay closed, in the same words however many there are, and opening the result opens them with it. Two pages whose open rows are the same differ only in how many results are closed, not in which; the section that holds a closed result still says it holds one: -- A card whose rows are a fixed panel, the same genes or loci for everyone, would tell the closed result's gene by the row it leaves out. So these cards close whole while any result to act on is closed, behind one box with the same words whatever the result: the known mobile element insertions, the repeat expansions, VNtyper, Lp(a), CHIP, the paralog copy numbers (SMN1, SMN2, HBA1, HBA2, the RCCX module), CPIC, CYP2D6, HLA, Locityper, the traits panel and the blood groups. The one list is `FIXED_PANELS` in `bin/live_results.py`, and `results.json` gives it as `veil.panels`. Their "Show the result" opens the next closed result, and the cards come back once every result is opened. The cost: a child's page with a closed result shows none of these cards until the results are opened. Their own findings (a medicine gene, a CHIP call) still show in their sections. A card can hold a fixed panel among its lines: the ClinVar card prints a line for each known deep intronic site, hit or not. Those lines close all together while any result to act on is closed and come back once every result is opened (`FIXED_LINES` in `bin/live_results.py`; `veil.lines` tags each of them `*panel*`). -- A closed finding carries no id in the page or the address, since its id names its gene and variant. It is known by its place among the page's closed findings (`#f/closed-2`). A link to `#f/` of a closed result lands on its card, and the address becomes that handle. The id comes back once the result is opened, and goes again when it is closed. +- A card whose rows are a fixed panel, the same genes or loci for everyone, would tell the closed result's gene by the row it leaves out. So these cards close whole while any result to act on is closed, behind one box with the same words whatever the result: the known mobile element insertions, the repeat expansions, VNtyper, Lp(a), CHIP, the paralog copy numbers (SMN1, SMN2, HBA1, HBA2, the RCCX module), CPIC, CYP2D6, HLA, Locityper, the traits panel and the blood groups. The one list is `FIXED_PANELS` in `bin/live_results.py`, and `results.json` gives it as `veil.panels`. Their "Show the result" opens the next closed result, and the cards come back once every result is opened. The cost: a child's page with a closed result shows none of these cards until the results are opened. Their own findings (a medicine gene, a CHIP call, a repeat) close with them, whatever gene they name, and so does a segment of the lead that names one (`veil.findings` and `veil.lead` tag them `*panel*`): a medicine finding that closed only when the closed result is in its gene would tell the gene. A card can hold a fixed panel among its lines or rows: the ClinVar card prints a line for each known deep intronic site, hit or not; the carrier list prints its two-hit line, which names the copy-number callers it read; and the callability card lists the genes this reference does not hold and the hard genes the reads did not cover, nearly the same for everyone. Those lines and rows close all together while any result to act on is closed and come back once every result is opened (`FIXED_LINES` in `bin/live_results.py`; `veil.lines` and `veil.rows` tag each of them `*panel*`). A row or line closes with a result only when it names the result's gene written as a gene symbol: a prose word ("set"), an ontology id (`HP:0001250`) or a unit after a number (`5 MB`, `48-52% GC`) names no gene, and the polygenic score card, which names scores and traits only, is never tagged (`NAMES_NO_GENE`). +- A closed finding carries no id in the page or the address, since its id names its gene and variant. It is known by its place among the page's closed findings (`#f/closed-2`). On a page that keeps a result closed, the address never holds the id of a finding the page can close, open or closed: once opened on purpose it is `#f/opened-`, its place among the opened ones, so no history entry keeps the id. Any other `#f/` on such a page, the id of a closed result or no id at all, lands the same way, on the first closed finding (`#f/closed-1`): the page never picks a closed card by its id, so a guessed id tells nothing. On the home page, a child's closed result links to the child's report, never to the finding's own page, whose address names it. 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. So is a page whose run state is unavailable or comes without its plugins, and a person no readable family lists while some family's pedigree cannot be read (the family card says so): an unknown role is never read as a standalone adult. The header search follows the same veil: a hit from a card the page keeps closed, or one that names a closed result, is left out, and so is a suggested gene that only such hits name. On the genome map a closed result has no mark, no tooltip, no line in the margin and no place in the address; a dashed chip above the map counts the closed results without naming a gene or a place, and its Open button asks once before it opens the next one. The chip counts every result to act on the page keeps closed, with a place on the map or without, so the count never tells whether a closed result has a place. Open opens one result per click, the next closed result in the page's order (the order of its closed cards), so the order does not tell either whether a closed result has a place. Opening a result there or with its card's "Show the result" is one switch: it opens on the map and in its card together, the chip then reads "Opened:" with the result's title and a button to close it again, and a reload closes it. A result with no place on the map opens in its section only: the map draws nothing new, and a chip reads "Opened in its section:" with the result's title, a link to its card and a button to close it again. That chip names only a result that is open, and it goes when the result closes. After Open the keyboard moves to "Show the result", and after "Show the result" or "Keep it closed" to the chips' first button, or to the map when no chip is left. A finding whose card the page shows stays on the map even when a closed result lies inside its gene's span: hiding it would show that the closed result sits in that gene. The page's own veil decides each of its findings, and a result opened with its card shows on the map even when it overlaps another result that is still closed. APOE is closed on the map on every page, an adult's too, until it is opened with its own chip or with the fold of its warning in the traits card, which is the same switch; the chip and the switch appear only once the traits card shows, so a replay before its step has none. While the traits card is closed whole on a child's page, the chip is the switch. The veil lives in the browser: the map document, like `results.json`, reaches the browser whole, with each row a closed result could reveal tagged with that result's id. diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index 5f2a8ffcb..eda441dc1 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -1423,28 +1423,39 @@ def planted_control(desc, a, z, problems): # What a reader of the son's page gets before any click: the whole document (every text and every attribute, the -# map's timing attributes aside), the address, and a hash of the map's canvas. +# map's timing attributes aside), the address, where the page is scrolled to and what has the focus, and a hash of +# the map's canvas. PAGE_JS = r"""JSON.stringify((function () { var c = document.documentElement.cloneNode(true); Array.prototype.forEach.call(c.querySelectorAll('[data-pgp-map-parse-ms],[data-pgp-map-paint-ms]'), function (e) { e.removeAttribute('data-pgp-map-parse-ms'); e.removeAttribute('data-pgp-map-paint-ms'); }); - var cv = document.querySelector('.gm-canvas'); - return { html: c.outerHTML, href: location.href, canvas: cv ? cv.toDataURL() : '' }; + // The root's data attributes come from scripts that answer in any order: sort them by name. + var at = Array.prototype.map.call(c.attributes, function (x) { return [x.name, x.value]; }).sort(); + at.forEach(function (x) { c.removeAttribute(x[0]); }); + at.forEach(function (x) { c.setAttribute(x[0], x[1]); }); + var cv = document.querySelector('.gm-canvas'), a = document.activeElement; + return { html: c.outerHTML, href: location.href + ' scroll ' + Math.round(window.scrollY) + ' focus ' + + (a ? a.tagName + '#' + a.id : ''), canvas: cv ? cv.toDataURL() : '' }; }()))""" -def page_now(b, base, hash_=""): +def page_now(b, base, hash_="", typed=False): """The son's page before any click, once its results, its charts and its map have drawn: (lines of its HTML, - its address, its canvas hash).""" + its address with its scroll position and focus, its canvas hash). TYPED: the page loads without HASH_, which is + then set as a reader types it into the address bar (a hashchange); its canvas is left out then (a hashchange + can move the map's camera).""" b.go("about:blank") b.wait("location.href === 'about:blank'") - b.go(f"{base}/runs/{OTHERS[1]}/{hash_}") + b.go(f"{base}/runs/{OTHERS[1]}/{'' if typed else hash_}") + # The son's family answers on its own request: wait for its name in the kicker too, or a pair can differ by it. b.wait("var d = document.documentElement.dataset; d.pgpResults && d.pgpCharts && d.pgpStatus === 'ready' && (" + MAP_READY - + ") && 'ready'", 15) + + ") && document.getElementById('kicker').textContent.indexOf('Family') >= 0 && 'ready'", 15) + if typed: + b.eval(f"location.hash = {json.dumps(hash_)}; true") time.sleep(0.8) # the margin follows the camera 140 ms after it settles got = json.loads(b.eval(PAGE_JS) or "{}") return (got.get("html") or "").replace("><", ">\n<").split("\n"), got.get("href") or "", \ - hashlib.sha256((got.get("canvas") or "").encode()).hexdigest()[:12] + "" if typed else hashlib.sha256((got.get("canvas") or "").encode()).hexdigest()[:12] def plant_summary(out, og, change): @@ -1515,7 +1526,13 @@ def second_hit(S): # Pairs of son's pages that differ only in which result is closed (or in what only a closed result names): the -# page before any click must be the same, byte for byte. +# page before any click must be the same, byte for byte. The two sides of each pair differ in the thing it guards: +# a gene that a fixed-panel card's own finding names (the medicine card's CYP2D6 and NAT2) against one no card names, +# a gene of the carrier list's two-hit line (SMN1) against one it does not name, a gene of the callability card's +# list of genes not on this reference (GSTT1) or of hard genes not covered (NBPF1) against one not on it, a gene +# spelled like a word or unit the report prints (HR, MB) against one that is not, and one guessed address on two +# pages where it is a closed result's id on one side only, loaded with the page and typed after it. +GUESS = "#f/cpsr-brca1-c-68-69del" INFER = (("two SNV results in different genes (BRCA1, BRCA2)", cpsr("BRCA1"), cpsr("BRCA2")), ("a deletion over BRCA1 and NBR2 against one over CFTR and CTTNBP2", sv_over("chr17:43044295-43144295", ["BRCA1", "NBR2"]), sv_over("chr7:117480025-117580025", ["CFTR", "CTTNBP2"])), @@ -1525,19 +1542,33 @@ def second_hit(S): ("a closed result in a gene of a known deep intronic site (ATM) against one in none (BRCA2)", with_sites("ATM"), with_sites("BRCA2")), ("a link to each of two closed results (#f/)", cpsr("BRCA1"), cpsr("BRCA2"), - ("#f/cpsr-brca1-c-68-69del", "#f/cpsr-brca2-c-68-69del"))) + ("#f/cpsr-brca1-c-68-69del", "#f/cpsr-brca2-c-68-69del")), + ("a gene of the medicine card's own finding (CYP2D6) against the other one (NAT2)", cpsr("CYP2D6"), cpsr("NAT2")), + ("a gene of the medicine card's own finding (CYP2D6) against one no card names (BRCA2)", cpsr("CYP2D6"), + cpsr("BRCA2")), + ("a gene the carrier list's two-hit line names (SMN1) against one it does not (BRCA2)", cpsr("SMN1"), + cpsr("BRCA2")), + ("a gene not on this reference (GSTT1) against one on it (BRCA2)", cpsr("GSTT1"), cpsr("BRCA2")), + ("a hard gene the reads did not cover (NBPF1) against one they did (BRCA2)", cpsr("NBPF1"), cpsr("BRCA2")), + ("a gene spelled like a word of the report (HR) against one that is not (BRCA2)", cpsr("HR"), + cpsr("BRCA2")), + ("a gene spelled like a unit of the report (MB) against one that is not (BRCA2)", cpsr("MB"), cpsr("BRCA2")), + ("one guessed address, a closed result's id on one page only (BRCA1 against BRCA2 closed)", cpsr("BRCA1"), + cpsr("BRCA2"), (GUESS, GUESS)), + ("the same guessed address typed after the page drew (BRCA1 against BRCA2 closed)", cpsr("BRCA1"), + cpsr("BRCA2"), (GUESS, GUESS), True)) def infer_problems(b, base, out, og, cases=INFER): """[problems]: for each pair of CASES, the son's page before any click differs (its HTML, an attribute, the address or the map's canvas), with the number of differing lines and the first ones.""" bad = [] - for what, one, two, *hashes in cases: + for what, one, two, *more in cases: got = [] - for change, h in zip((one, two), (hashes or [("", "")])[0]): + for change, h in zip((one, two), (more or [("", "")])[0]): keep = plant_summary(out, og, change) try: - got.append(page_now(b, base, h)) + got.append(page_now(b, base, h, typed=more[1:] == [True])) finally: unplant(keep) (h1, a1, c1), (h2, a2, c2) = got @@ -1558,6 +1589,104 @@ def sv_gene_problems(b, base, out, og): return [f"NBR2 on the page: ...{html[max(0, html.find('NBR2') - 120):html.find('NBR2') + 40]}..."] if "NBR2" in html else [] +def panel_words_problems(b, base, out, og): + """[problems]: with a closed result in CYP2D6, a gene the medicine card's own finding names, the medicine card shows + the fixed-panel box's words, never 'This section holds a result'.""" + keep = plant_summary(out, og, cpsr("CYP2D6")) + try: + page_now(b, base) + text = b.eval("(document.querySelector('#card-cpic .card-text') || {}).textContent || ''") + finally: + unplant(keep) + ok = "This section lists the same genes for everyone" in text and "This section holds" not in text + return [] if ok else [f"the medicine card says {text[:160]!r}"] + + +ADDRESS_JS = ("window.__seen = window.__seen || []; if (!window.__heard) { window.__heard = 1; ['hashchange', 'popstate']" + ".forEach(function (e) { window.addEventListener(e, function () { window.__seen.push(e + ' ' + location.hash); }); " + "}); } true") + + +def address_problems(b, base, out): + """[problems]: on the son's page, a result opened from its card and the reader's moves after it never put the + id of a result his page can close into the address or a history entry: #f/closed-1, Show the result, a link to + section 3, Back.""" + with open(os.path.join(out, "runs", OTHERS[1], "results.json")) as fh: + res = json.load(fh) + tags = (res.get("veil") or {}).get("findings") or {} + ids = [f["id"] for f in res["findings"] if f["level"] == "act" or f["id"] in tags] + seen = [] + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{OTHERS[1]}/#f/closed-1") + b.wait("document.documentElement.dataset.pgpResults && document.documentElement.dataset.pgpStatus === 'ready' && " + "document.querySelector('.finding[data-level=act] .veil button') && 'ready'", 15) + b.eval(ADDRESS_JS) + seen.append(b.eval("location.hash")) + b.eval("document.querySelector('.finding[data-level=act] .veil button').click(); true") + time.sleep(0.3) + seen.append(b.eval("location.hash")) + b.eval("location.hash = '#s3'; true") + time.sleep(0.3) + b.eval("history.back(); true") + time.sleep(0.5) + seen += [b.eval("location.hash")] + json.loads(b.eval("JSON.stringify(window.__seen)") or "[]") + bad = [h for h in seen if any(i in (h or "") for i in ids)] + if not str(seen[1]).startswith("#f/opened-"): + bad.append(f"the opened result's address: {seen[1]!r}, want #f/opened-") + return [f"addresses {seen}: {bad}"] if bad else [] + + +def home_now(b, base, out, change): + """The home page with the son's view (runs.json, live_results.overview) built from his summary with CHANGE applied: + the whole document.""" + p = os.path.join(out, "runs.json") + with open(p, "rb") as fh: + keep = fh.read() + with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as fh: + summary = json.load(fh) + change(summary.setdefault("sections", {})) + idx = json.loads(keep) + idx["runs"] = [dict(r, **live_results.overview(summary)) if r["name"] == OTHERS[1] else r for r in idx["runs"]] + try: + with open(p, "w") as fh: + json.dump(idx, fh) + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/") + b.wait("document.documentElement.dataset.pgpStatus === 'ready' && document.documentElement.dataset.pgpPeople " + "&& document.querySelector('#attention li') && 'ready'", 15) + time.sleep(0.3) + return b.eval("document.documentElement.outerHTML").replace("><", ">\n<").split("\n") + finally: + with open(p, "wb") as fh: + fh.write(keep) + + +def renamed(gene): + """The son's summary with his planted gene (KCNH2) named GENE everywhere: his first result to act on, the one the + home page lists, is then in GENE.""" + def change(S): + S.update(json.loads(json.dumps(S).replace("KCNH2", gene))) + return change + + +HOME = (("his first result in BRCA1 against BRCA2", renamed("BRCA1"), renamed("BRCA2")), + ("a gene of the medicine card's own finding (CYP2D6) against BRCA2", cpsr("CYP2D6"), cpsr("BRCA2"))) + + +def home_problems(b, base, out, cases=HOME): + """[problems]: the home page is the same whichever of the son's results is closed (his tile and the list of what + needs attention: no link to a closed result's own page, whose address names its gene).""" + bad = [] + for what, one, two in cases: + h1, h2 = home_now(b, base, out, one), home_now(b, base, out, two) + diff = [x for x in difflib.unified_diff(h1, h2, lineterm="", n=0) if x[:1] in "+-" and x[:3] not in ("+++", "---")] + if diff: + bad.append(f"home, {what}: {len(diff)} lines differ: {diff[:4]}") + return bad + + def infer_checks(b, base, out): """The son's page is a function of his open results and the count of his closed ones: two pages that differ only in which result is closed are the same before any click (INFER), and a closed deletion's second gene is named @@ -1573,11 +1702,55 @@ def infer_checks(b, base, out): "HTML, attributes, address and map canvas (" + "; ".join(x[0] for x in INFER) + ")", not bad, bad) bad = sv_gene_problems(b, base, out, og) check("browser: a closed deletion over two genes closes the rows that name only its second gene", not bad, bad) + bad = panel_words_problems(b, base, out, og) + check("browser: a fixed-panel card whose own finding names the closed result's gene shows the panel's words", not bad, + bad) + bad = address_problems(b, base, out) + check("browser: opening a closed result and moving on never puts its id in the address or a history entry", not bad, + bad) + bad = home_problems(b, base, out) + check("browser: the home page is the same whichever of the son's results is closed (" + "; ".join(x[0] for x in HOME) + + ")", not bad, bad) + home = os.path.join(out, "assets", "home.js") + with open(home) as fh: + keep_home = fh.read() + a = "x.r.url + (closedRuns[x.r.name] && x.f.level === 'act' ? '' : 'finding/' + encodeURIComponent(x.f.id) + '/')" + try: + if a not in keep_home: + check("control: home.js that links a closed result's own page (the planted line is in home.js)", False, a) + else: + with open(home, "w") as fh: + fh.write(keep_home.replace(a, "x.r.url + 'finding/' + encodeURIComponent(x.f.id) + '/'")) + control("home.js that links a closed result's own page", not home_problems(b, base, out, HOME[:1])) + finally: + with open(home, "w") as fh: + fh.write(keep_home) + first = live_results.panel_finding + try: + live_results.panel_finding = lambda f: False + control("a veil that tags a fixed-panel card's own finding by the closed result's gene", + not infer_problems(b, base, out, og, [x for x in INFER if "(NAT2)" in x[0]])) + finally: + live_results.panel_finding = first + fixed = live_results.FIXED_LINES + try: + live_results.FIXED_LINES = {k: v for k, v in fixed.items() if k != "carrier-list"} + control("a veil that tags the carrier list's two-hit line by its genes", + not infer_problems(b, base, out, og, [x for x in INFER if "two-hit" in x[0]])) + finally: + live_results.FIXED_LINES = fixed js = os.path.join(out, "assets", "person.js") with open(js) as fh: keep_js = fh.read() try: for desc, a, z, case in ( + # The guard is the pair of lines: either alone lands every guess the same way. + ("person.js that picks a closed card by a guessed id", + " if (f && !(veiled(f) && !m)) { return f; }\n return closedOrder().length ? findingById(closedOrder()[0]) : null;", + " return f || null;", [x for x in INFER if x[0].startswith("the same guessed")][0]), + ("person.js that picks a closed card by a guessed id, at load", + " if (f && !(veiled(f) && !m)) { return f; }\n return closedOrder().length ? findingById(closedOrder()[0]) : null;", + " return f || null;", [x for x in INFER if x[0].startswith("one guessed")][0]), ("person.js that puts a closed card's id back", "function handle(f) { return veiled(f) ? 'closed-' + (closedOrder().indexOf(f.id) + 1) : f.id; }", "function handle(f) { return f.id; }", INFER[0]), @@ -1592,6 +1765,18 @@ def infer_checks(b, base, out): with open(js, "w") as fh: fh.write(keep_js.replace(a, z)) control(desc, not infer_problems(b, base, out, og, [case])) + for desc, a, z, still in ( + ("person.js whose fixed-panel card says 'This section holds a result' first", "} else if (panelShut(c)) {", + "} else if (panelShut(c) && !closedFs.length) {", lambda: not panel_words_problems(b, base, out, og)), + ("person.js that writes an opened result's id into the address", + "function addr(f) { return !closable(f) ? f.id : veiled(f) ? handle(f) : 'opened-' + (openedOrder().indexOf(f.id) + 1); }", + "function addr(f) { return veiled(f) ? handle(f) : f.id; }", lambda: not address_problems(b, base, out))): + if a not in keep_js: + check(f"control: {desc} (the planted line is in person.js)", False, a) + continue + with open(js, "w") as fh: + fh.write(keep_js.replace(a, z)) + control(desc, still()) finally: with open(js, "w") as fh: fh.write(keep_js) @@ -1652,7 +1837,7 @@ def shown(): def problems(text, opened): page, cops, _ = json.loads(text) if text else ["", "", 0] out_ = [] - deep = page.count(live_results.FIXED_LINES["clinvar"]) + deep = page.count(live_results.FIXED_LINES["clinvar"][0]) if opened and not ("SMN1" in cops and "SMN2" in cops and "p.R175H" in page and "SMN1 copies" in page and deep == sites[0]): out_.append(f"every result opened, yet a dot, the call, the fact or a deep intronic line ({deep} of {sites[0]}) is " diff --git a/tests/test_live_results.py b/tests/test_live_results.py index 321525727..2562d3abb 100644 --- a/tests/test_live_results.py +++ b/tests/test_live_results.py @@ -394,7 +394,9 @@ def veil_problems(res, idx): out.append(f"{cid} rows tagged wrong: {bad or got}") exo = (v.get("rows") or {}).get("exomiser") or [] ranked = [f["id"] for f in res["findings"] if f["level"] != "act" and gene in f["title"]] - if sf not in exo or not ranked or v.get("findings") != {i: sf for i in ranked}: + # A fixed-panel card's own findings (a medicine gene) carry the panel tag, whatever the closed result. + named = {k: t for k, t in (v.get("findings") or {}).items() if t != live_results.PANEL} + if sf not in exo or not ranked or named != {i: sf for i in ranked}: out.append(f"the symptom ranking of {gene}: rows tagged {exo}, findings tagged {v.get('findings')}, want {ranked} -> {sf}") tagged = {(t, it["section"]): it.get("veil") for t, items in idx["terms"].items() for it in items if it.get("veil")} named = {(gene, it["section"]) for it in idx["terms"].get(gene) or []} @@ -503,6 +505,87 @@ def sv_genes_problems(): return out +# The veil's tables, pinned here so that an entry dropped from one turns this file red (each is checked by what +# veil() does with it, not by reading the table back): the fixed-panel cards, the fixed lines of a card (a sample +# line or row naming a gene, its card, and whether it is a row), the cards that name no gene, and the words that +# name no gene though a gene has their name. +WANT_PANELS = ("mei", "expansions", "vntyper", "lpa", "chip", "paralogs", "cpic", "cyp2d6", "hla", "locityper", + "traits-panel", "bloodtype") +WANT_LINES = (("clinvar", "Known deep intronic site (always reported): GLA c.640-801G>A chrX:1 C>T: hom", False), + ("carrier-list", "Two hits across callers (a deletion plus a variant on the other copy): none found; " + "deletions read from Parascopy's SMN1 count (step 35)", False), + ("callability", ["GSTT1", "not on this reference", ".", "ALT-only"], True), + ("callability", ["NBPF1", "not read", "69%", "segdup, collapsed"], True)) +WANT_NO_GENE = (("prs", ["Asthma", "PGS002727", "68.3 (EUR)", "2605/2872", "HR 1.57 (EUR, PPM014751)"], "HR"),) +WANT_NOT_A_GENE = (("SET", "the same set of genes"), ("MB", "0.0 MB in runs of 5 MB or more"), + ("HP", "Seizure (HP:0001250)"), ("GC", "reads per million with 48-52% GC")) +WANT_A_GENE = (("KCNH2", "A change in KCNH2."), ("C9ORF72", "C9orf72 repeat"), ("HLA-A", "HLA-A*02:01")) + + +def gene_of(text): + """The first upper-case word of TEXT (a list is a table row) that looks like a gene symbol.""" + t = " ".join(map(str, text)) if isinstance(text, list) else text + return next(w for w in re.findall(r"[A-Z][A-Z0-9]{2,}", t.split(":", 1)[-1] if not isinstance(text, list) else t) + if w not in ("ALT", "SMN")) + + +def table_problems(): + """What is wrong with veil()'s tables (WANT_*): a fixed-panel card missing from veil.panels, a fixed line or row + tagged by the gene it names instead of the panel tag, a card that names no gene tagged, a word that names no gene + read as one, or a gene symbol not read as one.""" + out = [] + found = lambda g: [{"id": "sf-" + g.lower(), "level": "act", "gene": g, "detail": g}] + chapters = [{"sections": [{"id": c, "state": "ok"} for c in WANT_PANELS]}] + got = live_results.veil({"sections": {}}, found("BRCA2"), chapters).get("panels") or [] + out += [f"card {c} is not in veil.panels" for c in WANT_PANELS if c not in got] + for card, item, is_row in WANT_LINES: + g = gene_of(item) + sec = {"id": card, "table": {"head": ["a", "b", "c", "d"], "rows": [item]}} if is_row else {"id": card, "lines": [item]} + v = live_results.veil({"sections": {}}, found(g), [{"sections": [sec]}]) + tag = ((v.get("rows" if is_row else "lines") or {}).get(card) or [None])[0] + if tag != live_results.PANEL: + out.append(f"{card}: {str(item)[:60]} tagged {tag} with a closed result in {g}, want the panel tag") + for card, row, g in WANT_NO_GENE: + sec = {"id": card, "table": {"head": ["a"] * len(row), "rows": [row]}, "lines": [" ".join(row)], "facts": [row[:2]]} + v = live_results.veil({"sections": {}}, found(g), [{"sections": [sec]}]) + if any((v.get(k) or {}).get(card) for k in ("rows", "lines", "facts")): + out.append(f"{card} names no gene, yet a closed result in {g} tags it: {v}") + for g, text in WANT_NOT_A_GENE: + if live_results.veil_id(live_results.closable(found(g)), text): + out.append(f"{text!r} read as naming {g}") + for g, text in WANT_A_GENE: + if not live_results.veil_id(live_results.closable(found(g)), text): + out.append(f"{text!r} not read as naming {g}") + return out + + +def table_controls(): + """Each entry of the veil's tables dropped in turn: table_problems() must fail every time.""" + keep = (live_results.FIXED_PANELS, live_results.FIXED_LINES, live_results.NAMES_NO_GENE, live_results.UNIT_RE) + first_words = live_results.gene_words + try: + for c in WANT_PANELS: + live_results.FIXED_PANELS = {k: v for k, v in keep[0].items() if k != c} + control(f"FIXED_PANELS without {c}", not table_problems()) + live_results.FIXED_PANELS = keep[0] + for card, words in keep[1].items(): + for w in words: + live_results.FIXED_LINES = dict(keep[1], **{card: tuple(x for x in words if x != w)}) + control(f"FIXED_LINES[{card}] without {w!r}", not table_problems()) + live_results.FIXED_LINES = keep[1] + live_results.NAMES_NO_GENE = {} + control("NAMES_NO_GENE empty", not table_problems()) + live_results.NAMES_NO_GENE = keep[2] + live_results.UNIT_RE = re.compile(r"(?!)") + control("gene_words with no units", not table_problems()) + live_results.UNIT_RE = keep[3] + live_results.gene_words = lambda t: {w.upper().rstrip("._-") for w in re.findall(r"[A-Za-z0-9][A-Za-z0-9._-]*", str(t))} + control("gene_words that reads every word, in any case, as a gene", not table_problems()) + finally: + (live_results.FIXED_PANELS, live_results.FIXED_LINES, live_results.NAMES_NO_GENE, live_results.UNIT_RE) = keep + live_results.gene_words = first_words + + def overview_problems(): """What is wrong with the home page's view of a child's run (overview): a finding that names a closed result (a symptom ranking of its gene) is listed among the first findings, or the closed lead still names it.""" @@ -623,6 +706,10 @@ def tagged(sample_dir): control("a veil that tags a known deep intronic site's line by its gene", not veil_lines_problems()) finally: live_results.FIXED_LINES = fixed + bad = table_problems() + check("8. every fixed-panel card, fixed line and gene-free card of the veil's tables closes as a panel or never, " + "and only a gene symbol written as one names a gene", not bad, bad) + table_controls() bad = overview_problems() check("8. a child's home tile lists no finding that names a closed result, and its closed lead names none", not bad, bad) From 6314a5c443ed9f4e260b83562bc1480cd2995cb0 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:25:51 +0100 Subject: [PATCH 27/27] test(live): open a closed result by its handle and read the map chip's opened address The page no longer finds a closed card by its id, so the map checks open a planted result by its place among the closed findings, and read an Opened in its section link (#f/opened-) as the finding it names. The medicine finding closes with its panel now, so the open-finding-over-a-closed-result check watches a carrier finding. closedActs() names its argument apart from mapClosed(), so the chip-count control plants its mistake in one place. --- assets/live/person.js | 2 +- tests/test_live_demo.py | 86 ++++++++++++++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/assets/live/person.js b/assets/live/person.js index ddfbdbbf8..5955a95c3 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -370,7 +370,7 @@ } function veiledOf(c) { return (c.findings || []).map(findingById).filter(function (f) { return f && veiled(f); }); } // The results to act on this page keeps closed, drawn yet or not. - function closedActs() { return ((results && results.findings) || []).filter(function (f) { return f.level === 'act' && veiled(f); }); } + function closedActs() { return ((results && results.findings) || []).filter(function (x) { return x.level === 'act' && veiled(x); }); } // A card of a fixed panel (results.veil.panels, bin/live_results.py FIXED_PANELS: the same genes or loci for // everyone, known in advance): a row or dot of it closed with a result would tell the result's gene by what is // missing. So it closes whole while this page keeps any result to act on closed, behind one box with the same diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index eda441dc1..d0f75cf89 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -645,15 +645,44 @@ def planted_closed(b, base): return out +# The handle of a closed finding, as the page counts it (person.js closedOrder()): the closed findings in the order +# of the sections, then any other; a result to act on is closed until opened, a finding tagged with one while that +# result is, a finding tagged PANEL while any result to act on is, a finding tagged with no finding of the page always. +HANDLE_JS = r"""fetch('results.json', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (r) { + var fid = %s, byId = {}, tags = (r.veil && r.veil.findings) || {}; + r.findings.forEach(function (f) { byId[f.id] = f; }); + function open(id) { return !!document.getElementById('f-' + id); } + var acts = r.findings.filter(function (f) { return f.level === 'act'; }); + var anyShut = acts.some(function (f) { return !open(f.id); }); + function shut(f) { + if (f.level === 'act') { return !open(f.id); } + var t = tags[f.id]; + if (!t || t === f.id) { return false; } + if (t === '*panel*') { return anyShut; } + return !byId[t] || !open(t); + } + var order = []; + (r.journal || []).forEach(function (s) { (s.findings || []).forEach(function (id) { + if (byId[id] && shut(byId[id]) && order.indexOf(id) < 0) { order.push(id); } }); }); + r.findings.forEach(function (f) { if (shut(f) && order.indexOf(f.id) < 0) { order.push(f.id); } }); + return order.indexOf(fid) + 1; +})""" + + def open_closed(b, fid): - """Opens the closed result FID as a reader who holds a link to it does: #f/ lands on its closed card, whose - address and element carry its handle (#f/closed-), never its id; then its "Show the result". False when the - link lands on no closed card.""" - b.eval("location.hash = %s; true" % json.dumps("#f/" + fid)) - if not b.wait("/^#f\\/closed-\\d+$/.test(location.hash) && document.getElementById('f-' + location.hash.slice(3)) " - "&& location.hash", 5): + """Opens the closed result FID as a reader does who knows its place among the page's closed findings: its handle + (#f/closed-), never its id, which the page does not use to find a closed card; then its "Show the result". + False when the handle names no closed card.""" + r = b.call("Runtime.evaluate", {"expression": HANDLE_JS % json.dumps(fid), "awaitPromise": True, + "returnByValue": True}, b.session) + n = (r.get("result") or {}).get("value") or 0 + if not n: + return False + b.eval("location.hash = %s; true" % json.dumps(f"#f/closed-{n}")) + if not b.wait("location.hash === %s && document.getElementById('f-closed-%d') && location.hash" + % (json.dumps(f"#f/closed-{n}"), n), 5): return False - b.eval("document.getElementById('f-' + location.hash.slice(3)).querySelector('.veil button').click(); true") + b.eval("document.getElementById('f-closed-%d').querySelector('.veil button').click(); true" % n) return True @@ -777,11 +806,11 @@ def write(p, text): check("browser: the home page and every finding page of the son name the planted gene and positions nowhere " "(the rail's next link and the related list included) until opened, and the symptom ranking's page " "opened on purpose shows it", not bad, bad) - a = "!!tagOf(x) && !opened[tagOf(x)]" + a = "return t === PANEL ? !opened[x.id] && actShut() : !!t && !opened[t];" if a not in keep[fj]: check("control: finding.js whose findings ignore their tags (the planted line is in finding.js)", False, a) else: - write(fj, keep[fj].replace(a, "false")) + write(fj, keep[fj].replace(a, "return false;")) control("finding.js whose findings ignore their tags", not planted_pages(b, base, out)) write(fj, keep[fj]) finally: @@ -2468,7 +2497,23 @@ def map_now(b): focus: f && f.classList.contains('gm-yes') ? 'gm-yes' : f === first ? 'first' : f === document.querySelector('.gm-canvas') ? 'canvas' : (f ? f.tagName + '.' + f.className : ''), opened: Array.prototype.map.call(document.querySelectorAll('.gm-chip.opened'), function (o) { var a = o.querySelector('a'); - return { kind: o.dataset.opened, text: o.textContent, href: a ? a.getAttribute('href') : '' }; }) }; + var raw = a ? a.getAttribute('href') : ''; + return { kind: o.dataset.opened, text: o.textContent, raw: raw, href: opened(raw) }; }) }; + // The finding an "Opened in its section" link names: #f/opened- is the n-th finding the page can close and + // shows opened, in the order of the sections (person.js openedOrder()). + function opened(raw) { + var m = /^#f\/opened-(\d+)$/.exec(raw); + if (!m) { return raw; } + if (!window.__pgpResults) { + var x = new XMLHttpRequest(); x.open('GET', 'results.json', false); x.send(); window.__pgpResults = JSON.parse(x.responseText); + } + var r = window.__pgpResults, tags = (r.veil && r.veil.findings) || {}, byId = {}, order = []; + r.findings.forEach(function (f) { byId[f.id] = f; }); + function ok(f) { return f && (f.level === 'act' || (tags[f.id] && tags[f.id] !== f.id)) && !!document.getElementById('f-' + f.id); } + (r.journal || []).forEach(function (s) { (s.findings || []).forEach(function (id) { if (ok(byId[id]) && order.indexOf(id) < 0) { order.push(id); } }); }); + r.findings.forEach(function (f) { if (ok(f) && order.indexOf(f.id) < 0) { order.push(f.id); } }); + return '#f/' + (order[Number(m[1]) - 1] || raw); + } }()))""" @@ -2507,6 +2552,9 @@ def chip_open(b): named = [o["href"] for o in after["opened"] if o["kind"] == "section" and o["href"][3:] not in after["ids"]] if named: bad.append(f"an Opened in its section chip names a result that is not open: {named}") + raw = [o["raw"] for o in after["opened"] if o["kind"] == "section" and not re.match(r"^#f/opened-\d+$", o["raw"])] + if raw: + bad.append(f"an Opened in its section chip links a result by its id: {raw}") leaked = [after["ids"][i] for i in after["closed"] if not re.match(r"^closed-\d+$", after["ids"][i] or "")] if leaked: bad.append(f"a closed card carries an id, not a handle: {leaked}") @@ -2822,8 +2870,13 @@ def act(fid, gene, viewer, title): [act("pl-iota", "PLIOTA", "chr7:117400000-117700000", "Planted iota deletion")], [{"id": "pl-kappa", "level": "review", "card": "exomiser", "gene": "CFTR", "title": "Planted kappa review", "detail": "CFTR", "text": "A planted review finding that names CFTR only."}], "pl-kappa", "chr7"), - ("the page's own CYP2D6 medicine finding over a closed variant naming another gene", - [act("pl-theta", "PLTHETA", "chr22:42125000", "Planted theta result")], [], "pgx-cyp2d6", "chr22")) + # The page's own CYP2D6 medicine finding closes with the medicine card while any result is closed (a fixed + # panel), so a carrier finding stands in for a gene-only finding of a card that is no panel. + ("a gene-only carrier finding (CYP2D6) over a closed variant naming another gene", + [act("pl-theta", "PLTHETA", "chr22:42125000", "Planted theta result")], + [{"id": "pl-lambda", "level": "carrier", "card": "carrier-list", "gene": "CYP2D6", + "title": "Planted lambda carrier", "detail": "CYP2D6", "text": "A planted carrier finding that names CYP2D6 only."}], + "pl-lambda", "chr22")) def under_problems(b, base, out, og): @@ -3007,10 +3060,13 @@ def open_order_problems(b, base, out, og): if k == 0: gone = [x["id"] for x in extra if not x["viewer"]][0] at = chips_now(b)["ids"].index(gone) if gone in chips_now(b)["ids"] else -1 - b.eval("var o = document.querySelector('.gm-chip.opened[data-opened=section] a[href=\"#f/%s\"]'); " - "o.parentNode.parentNode.querySelector('button').click(); true" % gone) + was = [o for o in chips_now(b)["opened"] if o["kind"] == "section"] + raw = [o["raw"] for o in was if o["href"] == f"#f/{gone}"] + b.eval("var o = document.querySelector('.gm-chip.opened[data-opened=section] a[href=\"%s\"]'); " + "o.parentNode.parentNode.querySelector('button').click(); true" % (raw[0] if raw else "")) after = chips_now(b) - if after["closed"] != [at] or any(o["kind"] == "section" and gone in o["href"] for o in after["opened"]): + left = [o for o in after["opened"] if o["kind"] == "section"] + if after["closed"] != [at] or len(left) != len(was) - 1: bad.append(f"{what}: Close it again on {gone}'s section chip: closed {after['closed']}, " f"opened chips {after['opened']}") finally: