diff --git a/assets/live/core.js b/assets/live/core.js new file mode 100644 index 00000000..64ec443f --- /dev/null +++ b/assets/live/core.js @@ -0,0 +1,129 @@ +// core.js: what the live site's pages share (home.html, index.html, family.html). Element builders, +// a fetch that sends the session and goes to the login on a 401, the pedigree symbol, dates, the +// caveat block, the facts list and the table. Text only through textContent and createElementNS: +// no innerHTML, no inline script, no eval. Everything hangs on window.PGPUI. +(function () { + 'use strict'; + var root = document.documentElement; + var SVGNS = 'http://www.w3.org/2000/svg'; + var blocked = []; + document.addEventListener('securitypolicyviolation', function (e) { + blocked.push(e.violatedDirective + ' ' + (e.blockedURI || '')); + root.setAttribute('data-pgp-blocked', blocked.join(' | ')); + }); + + function el(tag, attrs, text) { + var e = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (k) { + if (attrs[k] !== null && attrs[k] !== undefined && attrs[k] !== false) { e.setAttribute(k, attrs[k]); } + }); + if (text !== undefined && text !== null) { e.textContent = String(text); } + return e; + } + function svg(tag, attrs) { + var e = document.createElementNS(SVGNS, tag); + Object.keys(attrs || {}).forEach(function (k) { e.setAttribute(k, attrs[k]); }); + return e; + } + function add(parent) { + for (var i = 1; i < arguments.length; i++) { + var c = arguments[i]; + if (c === null || c === undefined || c === false) { continue; } + parent.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return parent; + } + function getJSON(url) { + return fetch(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; } + return r.ok ? r.json() : null; + }); + } + // The pedigree symbol: a square (male), a circle (female) or a diamond (not known); filled when sequenced. + function avatar(sex, filled, ratio) { + var a = el('span', { 'class': 'avatar sex-' + (sex === 'male' || sex === 'female' ? sex : 'unknown') + (filled ? ' filled' : '') , + 'aria-hidden': 'true' }); + if (typeof ratio === 'number') { + a.classList.add('ring'); + a.style.setProperty('--p', String(Math.max(0, Math.min(1, ratio)))); + } + add(a, el('span', { 'class': 'glyph' })); + return a; + } + function day(utc) { return utc ? String(utc).slice(0, 10) : ''; } + function firstSentence(t) { + var m = /^(.+?[.!?])(\s+[A-Z(]|$)/.exec(t || ''); + return m ? m[1] : t; + } + // The caveats of a card, word for word: the first sentence of the first one in view, all of them in a + // details element (in the page, so a reader or a search finds them). + function caveat(list) { + if (!list || !list.length) { return null; } + var box = el('div', { 'class': 'caveat' }); + var lead = firstSentence(list[0]); + add(box, el('p', { 'class': 'caveat-lead' }, lead)); + var d = el('details', { 'class': 'caveat-full' }); + add(d, el('summary', {}, list.length > 1 || lead !== list[0] ? 'Full caveat' : 'Caveat')); + list.forEach(function (t) { add(d, el('p', {}, t)); }); + add(box, d); + return box; + } + function facts(rows) { + if (!rows || !rows.length) { return null; } + var dl = el('dl', { 'class': 'facts' }); + rows.forEach(function (r) { + var w = el('div', { 'class': 'fact' }); + add(w, el('dt', {}, r[0]), el('dd', {}, r[1])); + add(dl, w); + }); + return dl; + } + // A table; on a phone each row becomes a block of label: value pairs (the label is data-label). + function table(t, limit) { + if (!t || !t.rows || !t.rows.length) { return null; } + limit = limit || 8; + var wrap = el('div', { 'class': 'table-wrap' }); + var tb = el('table', { 'class': 'rows' }); + var head = el('tr', {}); + t.columns.forEach(function (c) { add(head, el('th', { scope: 'col' }, c)); }); + add(tb, add(el('thead', {}), head)); + var body = el('tbody', {}); + t.rows.forEach(function (r, i) { + var tr = el('tr', i >= limit ? { 'class': 'extra', hidden: 'hidden' } : {}); + r.forEach(function (c, j) { add(tr, el('td', { 'data-label': t.columns[j] || '' }, c)); }); + add(body, tr); + }); + add(tb, body); + add(wrap, tb); + if (t.rows.length > limit) { + var b = el('button', { type: 'button', 'class': 'more' }, 'Show all ' + t.rows.length + ' rows'); + b.addEventListener('click', function () { + Array.prototype.forEach.call(body.querySelectorAll('tr.extra'), function (x) { x.hidden = false; }); + b.remove(); + }); + add(wrap, b); + } + // Rows past results.json's cap are only in the report: said apart from the button, never promised by it. + if (t.more > 0) { + add(wrap, el('p', { 'class': 'dim' }, t.more + ' more rows in the report')); + } + return wrap; + } + // role=tablist keyboard pattern: arrow keys move, Home and End jump; Enter or a click activates. + function tablist(list, onPick) { + list.addEventListener('keydown', function (e) { + var tabs = Array.prototype.slice.call(list.querySelectorAll('[role=tab]')); + var i = tabs.indexOf(document.activeElement); + if (i < 0) { return; } + var j = { ArrowDown: i + 1, ArrowRight: i + 1, ArrowUp: i - 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key]; + if (j === undefined) { return; } + e.preventDefault(); + j = (j + tabs.length) % tabs.length; + tabs[j].focus(); + onPick(tabs[j]); + }); + } + + window.PGPUI = { el: el, svg: svg, add: add, getJSON: getJSON, avatar: avatar, day: day, caveat: caveat, + facts: facts, table: table, tablist: tablist, firstSentence: firstSentence }; +}()); diff --git a/assets/live/family-page.js b/assets/live/family-page.js new file mode 100644 index 00000000..8a95a211 --- /dev/null +++ b/assets/live/family-page.js @@ -0,0 +1,238 @@ +// family-page.js: a family's page (assets/live/family.html), from family.json +// (bin/owngenome_live_family.py): the pedigree with each sequenced person a link to their page, the +// members with their run progress, what steps 38 and 39 planned, each couple's carrier screen in full +// (both carry, then X-linked, then one partner), who is related how, and each child's chromosome +// map. It draws what the steps wrote and computes nothing about genetics. Text through textContent, +// the drawing through createElementNS; images from this server only. +(function () { + 'use strict'; + var U = window.PGPUI; + if (!U) { return; } + var el = U.el, add = U.add, root = document.documentElement; + var RESULT = { shared: 'Both carry', x_linked: 'X-linked', one_partner: 'One partner' }; + + function section(title, cls) { + var s = el('section', { 'class': 'card ' + (cls || '') }); + add(s, el('h2', {}, title)); + return s; + } + function memberUrl(f, s) { return f.runs && f.runs[s] ? f.runs[s].url : null; } + + // The pedigree drawn from the layout the server gives: a sequenced person with a run here is a link. + function pedigree(f) { + var L = f.layout, st = f.state; + var box = el('figure', { 'class': 'pedigree-figure' }); + if (!L) { add(box, el('p', { 'class': 'dim' }, st.error || 'No pedigree.')); return box; } + var s = U.svg('svg', { viewBox: '0 0 ' + L.width + ' ' + L.height, role: 'group', 'aria-label': 'Pedigree of ' + st.name, + 'class': 'pedigree-svg', preserveAspectRatio: 'xMidYMin meet' }); + var lines = U.svg('g', { 'class': 'ped-lines' }); + L.lines.forEach(function (l) { lines.appendChild(U.svg('line', { x1: l[0], y1: l[1], x2: l[2], y2: l[3] })); }); + s.appendChild(lines); + s.style.maxWidth = Math.round(L.width * 1.1) + 'px'; // a two-person pedigree stays its own size + var h = L.size / 2; + L.people.forEach(function (p) { + var url = memberUrl(f, p.sample); + var g = U.svg(url ? 'a' : 'g', url ? { href: url, 'class': 'ped-person link', 'aria-label': p.sample + ': open their page' } + : { 'class': 'ped-person', 'aria-label': p.sample + (p.sequenced ? ': no run on this server' : ': not sequenced') }); + var cls = 'ped-shape' + (p.sequenced ? ' filled' : ''); + var shape = p.sex === 'male' ? U.svg('rect', { x: p.x - h, y: p.y - h, width: L.size, height: L.size, 'class': cls }) + : p.sex === 'female' ? U.svg('circle', { cx: p.x, cy: p.y, r: h, 'class': cls }) + : U.svg('polygon', { points: [p.x, p.y - h, p.x + h, p.y, p.x, p.y + h, p.x - h, p.y].join(' '), 'class': cls }); + g.appendChild(shape); + var t = U.svg('text', { x: p.x, y: p.y + h + 17, 'text-anchor': 'middle', 'class': 'ped-name' }); + t.textContent = p.sample; + g.appendChild(t); + var r = f.runs && f.runs[p.sample]; + if (r && r.attention) { + var b = U.svg('text', { x: p.x + h + 4, y: p.y - h + 4, 'class': 'ped-flag' }); + b.textContent = '▲ ' + r.attention; + g.appendChild(b); + } + var title = U.svg('title', {}); + title.textContent = p.sample + ', ' + (p.sex || 'sex not known') + (p.sequenced ? ', sequenced' : ', not sequenced'); + g.appendChild(title); + s.appendChild(g); + }); + add(box, s); + add(box, el('figcaption', { 'class': 'dim' }, 'Square: male. Circle: female. Diamond: sex not known. Filled: sequenced. ' + + 'A person with a run on this server opens their page; ▲ counts the cards their report flags.')); + var d = el('details', { 'class': 'pedigree-image' }); + add(d, el('summary', {}, 'The drawing as an image')); + var img = el('img', { src: st.pedigree, alt: 'Pedigree of ' + st.name, 'class': 'pedigree' }); + add(d, img); + add(box, d); + return box; + } + + function members(f) { + var s = section('Members', 'members'); + var ul = el('ul', { 'class': 'people' }); + f.state.members.forEach(function (m) { + var r = f.runs && f.runs[m.sample]; + var li = el('li', { 'class': 'person-card' + (r ? ' has-run' : '') }); + var inner = r ? el('a', { href: r.url, 'class': 'person-link' }) : el('div', { 'class': 'person-link' }); + add(inner, U.avatar(m.sex, m.sequenced, r && r.planned ? r.done / r.planned : null)); + var txt = el('div', { 'class': 'person-text' }); + add(txt, el('span', { 'class': 'person-name' }, m.sample)); + var rel = []; + if (m.father || m.mother) { rel.push('child of ' + [m.father, m.mother].filter(Boolean).join(' and ')); } + if (m.partner) { rel.push('partner of ' + m.partner); } + if (rel.length) { add(txt, el('span', { 'class': 'person-place' }, rel.join('; '))); } + var state = !m.sequenced ? 'Not sequenced' : !r ? 'No run on this server' + : r.state === 'finished' ? 'Finished' : r.done + ' of ' + r.planned + ' steps'; + add(txt, el('span', { 'class': 'person-state' }, state)); + if (r && r.results) { + add(txt, el('span', { 'class': 'person-look' + (r.attention ? ' attn' : '') }, + r.attention ? r.attention + ' worth a look' : 'Nothing flagged; see the caveats')); + } + add(inner, txt); + add(li, inner); + add(ul, li); + }); + add(s, ul); + return s; + } + + function analyses(f) { + var plan = f.state.plan || []; + var p = el('p', { 'class': 'analyses' }); + if (!plan.length) { add(p, 'Analyses: none planned yet (step 38 writes family_plan.tsv).'); return p; } + add(p, el('span', { 'class': 'label' }, 'Analyses: ')); + plan.forEach(function (r, i) { + if (i) { add(p, ', '); } + var word = r.decision === 'runs' ? r.analysis.replace(/_/g, ' ') : r.analysis.replace(/_/g, ' ') + ' (' + r.decision + ')'; + add(p, el('span', { 'class': 'plan-item d-' + r.decision, title: r.reason || '' }, word)); + }); + return p; + } + + function couples(f) { + var s = section('Couple carrier screen', 'couples'); + add(s, el('p', { 'class': 'caveat-lead' }, f.couple_note)); + if (!f.state.couples.length) { add(s, el('p', { 'class': 'dim' }, 'The pedigree names no partners.')); } + f.state.couples.forEach(function (c) { + var p = el('p', { 'data-couple': c.a + ' ' + c.b, 'class': 'couple-line' }); + if (!c.screened) { + p.textContent = c.a + ' and ' + c.b + ': not screened yet (step 39).'; + add(s, p); + 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. + p.textContent = c.a + ' and ' + c.b + ': ' + c.shared + ' gene(s) where both carry a finding, ' + c.x_linked + + ' X-linked, ' + one + ' where one partner carries a finding, unless the other partner carries something the ' + + 'screen does not see. '; + if (c.report) { add(p, el('a', { href: c.report }, 'What the screen does not see')); } + add(s, p); + add(s, U.table({ columns: ['Result', 'Gene', 'Condition', 'Chance for a child', c.a, c.b, 'Why'], + rows: rows.map(function (r) { + var aFirst = r.partner_a === c.a; + return [RESULT[r.result] || r.result, r.gene, r.condition, r.offspring_risk, + aFirst ? r.findings_a : r.findings_b, aFirst ? r.findings_b : r.findings_a, r.explanation]; + }) }, 12)); + }); + return s; + } + + function related(f) { + var s = section('Who is related how', 'related'); + var rel = f.relationships || [], ibd = f.ibd_pairs || []; + if (!rel.length && !ibd.length) { add(s, el('p', { 'class': 'dim' }, 'Not run yet (step 38).')); return s; } + var byPair = {}; + ibd.forEach(function (r) { byPair[[r.sample_a, r.sample_b].sort().join(' ')] = r; }); + var rows = rel.map(function (r) { + var i = byPair[[r.sample_a, r.sample_b].sort().join(' ')] || {}; + return [r.sample_a + ' and ' + r.sample_b, r.declared, r.relatedness, r.ibs0, r.ibs2, i.shared_cm || '.', + i.degree || '.', r.verdict, r.reason]; + }); + ibd.forEach(function (i) { + if (!rel.some(function (r) { return [r.sample_a, r.sample_b].sort().join(' ') === [i.sample_a, i.sample_b].sort().join(' '); })) { + rows.push([i.sample_a + ' and ' + i.sample_b, i.declared, i.relatedness, '.', '.', i.shared_cm, i.degree, '.', '.']); + } + }); + add(s, U.table({ columns: ['Pair', 'Declared', 'Relatedness', 'IBS0', 'IBS2', 'Shared cM', 'Degree', 'Verdict', 'Reason'], + rows: rows }, 20)); + if (!ibd.length) { add(s, el('p', { 'class': 'dim' }, 'Shared segments need step 26b’s panel: only the somalier half is here.')); } + return s; + } + + function children(f) { + var s = section('Inherited stretches', 'children'); + if (!f.state.children.length) { add(s, el('p', { 'class': 'dim' }, 'The pedigree names no child.')); } + f.state.children.forEach(function (c) { + var d = el('details', { 'data-child': c.child }); + add(d, el('summary', {}, c.child + ': father ' + (c.father || 'not named') + ', mother ' + (c.mother || 'not named'))); + if (c.segments) { + add(d, el('img', { src: c.segments, alt: 'Chromosome map of ' + c.child, 'class': 'segments' })); + } else { + add(d, el('p', { 'class': 'dim' }, 'No chromosome map yet: step 38 draws one for a child with a sequenced parent.')); + } + add(s, d); + }); + return s; + } + + function more(f) { + var s = section('Plan and reports', 'plan'); + var plan = f.state.plan || []; + if (plan.length) { + add(s, U.table({ columns: ['Analysis', 'Members', 'Decision', 'Reason'], + rows: plan.map(function (r) { return [r.analysis, r.members, r.decision, r.reason]; }) }, 20)); + } + 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))); }); + add(s, ul); + } + return s; + } + + function draw(f) { + var main = document.getElementById('family'); + main.textContent = ''; + var page = el('section', { 'class': 'family-page', 'data-plugin': 'family', 'aria-label': 'Family ' + f.state.name }); + if (f.state.error) { + add(page, el('p', {}, f.state.error)); + add(main, page); + root.setAttribute('data-pgp-status', 'ready'); + return; + } + var top = el('div', { 'class': 'family-top' }); + var ped = section('Pedigree', 'ped'); + add(ped, pedigree(f), analyses(f)); + add(top, ped, members(f)); + add(page, top); + var grid = el('div', { 'class': 'family-grid' }); + add(grid, couples(f), related(f)); + add(page, grid, children(f)); + if ((f.state.plan || []).length || f.state.reports.length) { add(page, more(f)); } + add(main, page); + root.setAttribute('data-pgp-status', 'ready'); + } + + // 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; + function failed() { + if (shown !== null) { return; } + var main = document.getElementById('family'); + main.textContent = ''; + var b = el('button', { type: 'button', 'class': 'more' }, 'Try again'); + b.addEventListener('click', function () { main.textContent = ''; add(main, el('p', { 'class': 'dim' }, 'Loading the family.')); fetchFamily(); }); + add(main, el('p', {}, 'The family could not be loaded.'), b); + root.setAttribute('data-pgp-status', 'offline'); + } + function fetchFamily() { + return U.getJSON('family.json').then(function (f) { + if (!f) { failed(); return; } + var k = JSON.stringify(f); + if (k !== shown) { shown = k; draw(f); } + }).catch(failed); + } + function start() { + fetchFamily(); + window.setInterval(function () { if (!document.hidden) { fetchFamily(); } }, 30000); + } + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } +}()); diff --git a/assets/live/family.html b/assets/live/family.html new file mode 100644 index 00000000..ebe0d057 --- /dev/null +++ b/assets/live/family.html @@ -0,0 +1,22 @@ + + + + + +Family {{run}}: OwnGenome + + + +{{plugins}} + + +
+ OwnGenome + + +
+
+

Loading the family.

+
+ + diff --git a/assets/live/home.html b/assets/live/home.html new file mode 100644 index 00000000..48ef0b22 --- /dev/null +++ b/assets/live/home.html @@ -0,0 +1,21 @@ + + + + + +OwnGenome: people + + + + + +
+ OwnGenome + +
+
+
+ +
+ + diff --git a/assets/live/home.js b/assets/live/home.js new file mode 100644 index 00000000..51d58ca7 --- /dev/null +++ b/assets/live/home.js @@ -0,0 +1,105 @@ +// home.js: the live site's home page (assets/live/home.html): every person, grouped by family, from +// /runs.json (bin/owngenome_live.py runs_index). A card shows the pedigree symbol, the person's place +// in the family, the run's progress and how many cards the report flags; runs still going come first +// in each family. The plain list of runs in the page is what shows without scripts. +(function () { + 'use strict'; + var U = window.PGPUI; + if (!U) { return; } + var el = U.el, add = U.add; + var root = document.documentElement; + + function role(m, fam) { + var kids = fam.members.filter(function (x) { return x.father === m.sample || x.mother === m.sample; }); + if (kids.length) { return { male: 'father', female: 'mother' }[m.sex] || 'parent'; } + if (fam.members.some(function (x) { return x.sample === m.father || x.sample === m.mother; })) { + return { male: 'son', female: 'daughter' }[m.sex] || 'child'; + } + return m.partner ? 'partner' : 'member'; + } + function progressText(r) { + if (!r) { return 'No run on this server'; } + if (r.state === 'finished') { return 'Finished' + (r.finished_utc ? ' ' + U.day(r.finished_utc) : ''); } + if (r.state === 'running') { return 'Running: ' + r.done + ' of ' + r.planned + ' steps'; } + if (!r.planned) { return 'Not started'; } + return r.done + ' of ' + r.planned + ' steps'; + } + function lookText(r) { + if (!r || !r.results) { return r && r.done ? 'Results so far on the page' : ''; } + if (r.attention === null || r.attention === undefined) { return 'Results on the page'; } + return r.attention ? r.attention + ' worth a look' : 'Nothing flagged; see the caveats'; + } + function personCard(name, sex, sequenced, place, r) { + var li = el('li', { 'class': 'person-card' + (r ? ' has-run' : '') + (r && r.state === 'running' ? ' running' : '') }); + var ratio = r && r.planned ? r.done / r.planned : null; + var inner = r ? el('a', { href: r.url, 'class': 'person-link' }) : el('div', { 'class': 'person-link' }); + add(inner, U.avatar(sex, sequenced, ratio)); + var txt = el('div', { 'class': 'person-text' }); + add(txt, el('span', { 'class': 'person-name' }, name)); + if (place) { add(txt, el('span', { 'class': 'person-place' }, place)); } + if (sequenced || r) { + if (r && r.planned) { + var bar = el('span', { 'class': 'bar', role: 'img', 'aria-label': r.done + ' of ' + r.planned + ' steps done' }); + var fill = el('span', { 'class': 'bar-fill' }); + fill.style.setProperty('--p', String(ratio)); + add(bar, fill); + add(txt, bar); + } + add(txt, el('span', { 'class': 'person-state' }, progressText(r))); + var lk = lookText(r); + if (lk) { add(txt, el('span', { 'class': 'person-look' + (r && r.attention ? ' attn' : '') }, lk)); } + } else { + add(txt, el('span', { 'class': 'person-state' }, 'Not sequenced')); + } + add(inner, txt); + add(li, inner); + return li; + } + function rank(r) { return !r ? 3 : r.state === 'running' ? 0 : r.state === 'finished' ? 1 : 2; } + + function draw(data) { + var main = document.getElementById('home'); + main.textContent = ''; + var byName = {}; + (data.runs || []).forEach(function (r) { byName[r.name] = r; }); + var inFamily = {}; + (data.families || []).forEach(function (f) { + var block = el('section', { 'class': 'family-block', 'data-family': f.name }); + var h = el('header', { 'class': 'family-head' }); + var seq = f.members.filter(function (m) { return m.sequenced; }).length; + var h2 = el('h2', {}); + add(h2, el('a', { href: f.url }, 'Family ' + f.name)); + add(h, h2, el('span', { 'class': 'dim' }, seq + ' of ' + f.members.length + ' sequenced')); + add(h, el('a', { href: f.url, 'class': 'open' }, 'Open the family page')); + add(block, h); + var ul = el('ul', { 'class': 'people' }); + f.members.slice().sort(function (a, b) { return rank(byName[a.sample]) - rank(byName[b.sample]); }).forEach(function (m) { + inFamily[m.sample] = true; + add(ul, personCard(m.sample, m.sex, m.sequenced, role(m, f), byName[m.sample])); + }); + add(block, ul); + add(main, block); + }); + var loose = (data.runs || []).filter(function (r) { return !inFamily[r.name]; }) + .sort(function (a, b) { return rank(a) - rank(b) || (a.name < b.name ? -1 : 1); }); + if (loose.length) { + var block = el('section', { 'class': 'family-block' }); + add(block, add(el('header', { 'class': 'family-head' }), el('h2', {}, (data.families || []).length ? 'Not in a family' : 'People'))); + var ul = el('ul', { 'class': 'people' }); + loose.forEach(function (r) { add(ul, personCard(r.name, r.declared_sex, true, null, r)); }); + add(block, ul); + add(main, block); + } + if (!loose.length && !(data.families || []).length) { + add(main, el('p', { 'class': 'empty' }, 'No run under this folder yet. Start one with scripts/run-all.sh and it shows here.')); + } + root.setAttribute('data-pgp-people', String(main.querySelectorAll('.person-card').length)); + root.setAttribute('data-pgp-status', 'ready'); + } + + function start() { + U.getJSON(root.getAttribute('data-data') || '/runs.json').then(function (d) { if (d) { draw(d); } }) + .catch(function () { root.setAttribute('data-pgp-status', 'offline'); }); + } + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } +}()); diff --git a/assets/live/index.html b/assets/live/index.html index fb3208bc..6f170ec4 100644 --- a/assets/live/index.html +++ b/assets/live/index.html @@ -3,31 +3,49 @@ -{{run}}: live run +{{run}}: OwnGenome + + {{plugins}} - -
-

{{run}} loading

- + +
+ OwnGenome + +
+
-
-
-
-
-
- -
+
+

{{run}} loading

+

+ +

+
+
+ +
+ +
+
+
+
+
+
+ +
+
+
+
diff --git a/assets/live/live.css b/assets/live/live.css index c0c4c4fd..08f6ce5e 100644 --- a/assets/live/live.css +++ b/assets/live/live.css @@ -1,65 +1,320 @@ -/* live.css: the dark theme of the live page and the login page (assets/live). */ -:root { color-scheme: dark; --bg: #0f1216; --card: #171b21; --line: #2a313b; --fg: #e3e7ec; --muted: #8b96a3; - --waiting: #3a4350; --running: #2f6fb3; --done: #2e7d4f; --failed: #b33a3a; --skipped: #4a3f5c; - --notfinished: #8a6d1f; --notplanned: #23282f; --accent: #6aa9ff; --attention: #e0a23a; } +/* live.css: the live site's pages (assets/live): home, a person, a family, the login. + Dark first. No green anywhere: a result that ran is not a finding. --fail marks a failed step, + never a result. A tone is always a word and a symbol, never a colour alone. */ +:root { color-scheme: dark light; + --bg: #14181f; --card: #1d232d; --card2: #232a36; --rule: #2f3847; --text: #e4e7ec; --dim: #9aa3b2; + --info: #7fa6d9; --attn: #e0a64b; --low: #a99ad6; --fail: #c8664f; --focus: #7fa6d9; + --anc1: #6f8fb8; --anc2: #b98a5e; --anc3: #8b7fb5; --anc4: #5f9ea0; --anc5: #a8787f; + --serif: ui-serif, "Iowan Old Style", "Palatino Linotype", Georgia, serif; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mono: ui-monospace, Menlo, monospace; + --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px; --s7: 48px; + --r-card: 8px; --r-chip: 3px; } +@media (prefers-color-scheme: light) { :root { + --bg: #f6f7f9; --card: #fff; --card2: #f0f2f5; --rule: #d5dae1; --text: #1b2029; --dim: #566070; + --info: #2f5f9e; --attn: #8a5a00; --low: #5f4ea0; --fail: #a33a26; --focus: #2f5f9e; } } + * { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } -a { color: var(--accent); } -header { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--line); } -header h1 { font-size: 18px; margin: 0; } -header nav a { margin-left: 14px; } -main { display: flex; gap: 16px; padding: 16px; align-items: flex-start; } -.map-wrap { flex: 1 1 auto; overflow: auto; min-width: 0; } -aside { flex: 0 0 360px; display: flex; flex-direction: column; gap: 12px; } -.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; } -.card h2 { font-size: 15px; margin: 0 0 8px; } -.muted { color: var(--muted); } -.badge { font-size: 12px; padding: 2px 8px; border-radius: 10px; background: var(--waiting); vertical-align: middle; } -.badge.run-running { background: var(--running); } -.badge.run-finished { background: var(--done); } -.legend { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; color: var(--muted); font-size: 12px; } -.legend .swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 4px; vertical-align: -2px; } -.edges line { stroke: var(--line); stroke-width: 1.2; } +html { -webkit-text-size-adjust: 100%; } +body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 var(--sans); font-variant-numeric: tabular-nums; } +a { color: var(--info); text-underline-offset: 2px; } +a:hover { text-decoration-thickness: 2px; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; } +h1, h2, h3 { font-weight: 600; margin: 0; } +.dim, .muted { color: var(--dim); } +[hidden] { display: none !important; } +button { font: inherit; background: var(--card2); color: var(--text); border: 1px solid var(--rule); border-radius: 6px; + padding: 5px 12px; cursor: pointer; } +button:hover { border-color: var(--dim); } + +/* --- the header every page shares --- */ +.top { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: var(--s4); padding: 10px var(--s5); + background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(6px); border-bottom: 1px solid var(--rule); } +.brand { font-family: var(--serif); font-size: 19px; color: var(--text); text-decoration: none; letter-spacing: .01em; } +.crumbs { display: flex; gap: var(--s2); align-items: baseline; color: var(--dim); min-width: 0; overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; } +.crumbs::before { content: "/"; color: var(--rule); } +.crumbs a { color: var(--dim); } +.crumbs .sep { color: var(--rule); } +.crumbs #run-name { color: var(--text); } +.links { margin-left: auto; display: flex; gap: var(--s4); white-space: nowrap; } +.search-slot + .links { margin-left: 0; } +.logout { margin: 0 0 0 auto; } +.demo-note { margin: 0 0 0 auto; color: var(--dim); font-size: 13px; text-align: right; } + +/* the search box (plugins/search.js) in the header */ +.search-slot { margin-left: auto; position: relative; width: min(320px, 40vw); } +.search-slot > section.plugin { background: none; border: 0; padding: 0; margin: 0; } +.search-slot h2, .search-slot .search-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } +.search-slot form { margin: 0; } +.search-slot input[type=search] { width: 100%; font: inherit; font-size: 14px; color: var(--text); background: var(--card); + border: 1px solid var(--rule); border-radius: 6px; padding: 6px 10px; } +.search-slot input[type=search]:disabled { color: var(--dim); } +.search-results { display: none; position: absolute; right: 0; top: calc(100% + 6px); width: min(460px, 92vw); max-height: 70vh; + overflow: auto; background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s3) var(--s4); + box-shadow: 0 12px 32px rgba(0, 0, 0, .35); } +.search-slot:focus-within .search-results:not(:empty) { display: block; } +.search-results ul { padding-left: 1.1em; margin: 4px 0; } +.search-results h3 { font-size: 15px; } +.search-results .suggestions button { padding: 1px 8px; margin: 2px; } + +/* --- a person: the head, the strip --- */ +.person-head { max-width: 1120px; margin: 0 auto; padding: var(--s5) var(--s5) var(--s3); } +.person-head h1 { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; font-family: var(--serif); font-size: 30px; + font-weight: 500; line-height: 1.2; } +.person-line { color: var(--dim); margin: var(--s2) 0 var(--s3); } +.strip { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 2px; height: 10px; border-radius: 3px; overflow: hidden; + text-decoration: none; } +.strip .cell { background: var(--rule); } +.strip .cell.st-done { background: var(--info); } +.strip .cell.st-running { background: repeating-linear-gradient(45deg, var(--info) 0 3px, var(--card2) 3px 6px); } +.strip .cell.st-failed { background: var(--fail); } +.strip .cell.st-skipped, .strip .cell.st-not-finished { background: var(--card2); } +.strip-text { color: var(--dim); font-size: 13px; margin: 6px 0 0; } +.badge { font: 600 12px/1 var(--sans); padding: 4px 8px; border-radius: var(--r-chip); border: 1px solid var(--rule); color: var(--dim); + letter-spacing: .02em; } +.badge.run-running { color: var(--info); border-color: var(--info); } +.badge.run-finished { color: var(--text); } + +/* the pedigree symbol, with its ring showing progress */ +.avatar { --p: 0; position: relative; display: inline-grid; place-items: center; width: 34px; height: 34px; flex: none; } +.avatar .glyph { width: 20px; height: 20px; border: 2px solid var(--text); } +.avatar.sex-female .glyph { border-radius: 50%; } +.avatar.sex-unknown .glyph { width: 16px; height: 16px; transform: rotate(45deg); } +.avatar.filled .glyph { background: var(--text); } +.avatar.ring::before { content: ""; position: absolute; inset: 0; border-radius: 50%; + background: conic-gradient(var(--info) calc(var(--p) * 360deg), var(--rule) 0); + -webkit-mask: radial-gradient(circle, transparent 14px, #000 15px); mask: radial-gradient(circle, transparent 14px, #000 15px); } + +/* --- the layout: the rail and the content --- */ +.layout { max-width: 1120px; margin: 0 auto; padding: 0 var(--s5) var(--s7); display: grid; grid-template-columns: 200px minmax(0, 1fr); + gap: var(--s5); align-items: start; } +.rail { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; padding-top: var(--s3); } +.rail [role=tab] { display: flex; justify-content: space-between; align-items: center; gap: var(--s2); padding: 7px 10px; + border-radius: 6px; color: var(--dim); text-decoration: none; border-left: 3px solid transparent; } +.rail [role=tab]:hover { color: var(--text); background: var(--card); } +.rail [role=tab][aria-selected=true] { color: var(--text); background: var(--card); border-left-color: var(--info); } +.rail [data-tab=progress], .rail [data-tab=leaves] { font-size: 14px; } +.rail [data-tab=leaves] { margin-top: var(--s3); } +.count { font-size: 12px; font-weight: 700; min-width: 22px; text-align: center; padding: 1px 6px; border-radius: 10px; + color: var(--bg); background: var(--attn); } +#content { min-width: 0; } + +.view-head { margin: var(--s3) 0 var(--s4); } +.question { font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.25; } +.view-head p { margin: 4px 0 0; max-width: 75ch; } +.cards { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s4); } +#results, #progress, .view { min-width: 0; } + +/* --- a card --- */ +.card { min-width: 0; overflow-wrap: anywhere; background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s4) var(--s5); } +.card > h2 { font-size: 15px; margin-bottom: var(--s2); } +.result { position: relative; scroll-margin-top: 80px; } +.result.attn { border-left: 4px solid var(--attn); padding-left: calc(var(--s5) - 3px); } +.result.g-waiting { border-style: dashed; background: transparent; } +.result.g-notrun, .result.g-empty { background: repeating-linear-gradient(45deg, transparent 0 9px, color-mix(in srgb, var(--rule) 35%, transparent) 9px 10px); } +.result.target { outline: 2px solid var(--focus); outline-offset: 3px; } +.card-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3); flex-wrap: wrap; margin-bottom: var(--s2); } +.card-head h3 { font-size: 17px; } +.card-head h3 a { color: var(--text); text-decoration: none; } +.card-head h3 a:hover { text-decoration: underline; } +.chips { display: flex; gap: 6px; flex-wrap: wrap; } +.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-chip); + border: 1px solid var(--rule); color: var(--dim); white-space: nowrap; } +.chip .sym { font-size: 10px; } +.chip-attn { color: var(--attn); border-color: var(--attn); } +.chip-info { color: var(--dim); } +.chip-recheck { color: var(--low); border-color: var(--low); } +.state-text { color: var(--dim); margin: 0 0 var(--s2); } +.headline { margin: 0 0 var(--s3); max-width: 75ch; } +.note-stale { color: var(--attn); margin: 0 0 var(--s2); } +.empty { color: var(--dim); max-width: 75ch; } +.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--s2) var(--s5); margin: 0 0 var(--s3); } +.fact { min-width: 0; } +.fact dt { color: var(--dim); font-size: 13px; } +.fact dd { margin: 0; overflow-wrap: anywhere; } +.lines { margin: 0 0 var(--s3); padding-left: 1.1em; max-width: 75ch; color: var(--text); } +.lines li { margin: 2px 0; } +.table-wrap { overflow-x: auto; margin: 0 0 var(--s3); } +table.rows { border-collapse: collapse; width: 100%; font-size: 14px; overflow-wrap: normal; } +table.rows th { text-align: left; color: var(--dim); font-weight: 600; font-size: 13px; padding: 6px 10px; border-bottom: 1px solid var(--rule); } +table.rows th { white-space: nowrap; } +table.rows td { padding: 6px 10px; vertical-align: top; overflow-wrap: break-word; min-width: 4ch; } +table.rows tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--rule) 30%, transparent); } +.more { margin-top: 6px; font-size: 13px; } +.caveat { border-top: 1px solid var(--rule); margin-top: var(--s3); padding-top: var(--s3); font-size: 13px; line-height: 1.55; color: var(--dim); } +.caveat p { margin: 0 0 6px; max-width: 75ch; } +.caveat-lead::before { content: "\24d8"; margin-right: 6px; color: var(--info); } +.caveat summary { cursor: pointer; color: var(--info); } +.card-links { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4); margin: var(--s3) 0 0; font-size: 14px; } +.behind { margin: 0 0 var(--s3); } +.behind summary { cursor: pointer; color: var(--attn); } +.explore { font-size: 14px; color: var(--dim); } +img.plot { max-width: 100%; height: auto; background: #fff; border-radius: 4px; margin-bottom: var(--s3); } + +/* charts (plugins/charts.js) inside a card */ +.chart-slot:empty { display: none; } +figure.chart { margin: 0 0 var(--s3); } +figure.chart h3 { font-size: 14px; color: var(--dim); margin-bottom: 4px; } +figure.chart > .caveat { border: 0; padding: 0; margin: 0 0 6px; } +figure.chart svg { width: 100%; height: auto; max-width: 100%; } +.chart-detail { background: var(--card2); border: 1px solid var(--rule); border-radius: 6px; padding: var(--s3); margin: 0 0 var(--s3); } +.chart-detail h3 { font-size: 15px; } + +/* Look first */ +.look-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); counter-reset: none; } +.look-list li { background: var(--card); border: 1px solid var(--rule); border-left: 4px solid var(--attn); border-radius: var(--r-card); + padding: var(--s3) var(--s4); } +.look-list a { display: flex; gap: var(--s3); align-items: baseline; text-decoration: none; } +.look-ch { color: var(--dim); font-size: 13px; min-width: 76px; } +.look-title { font-weight: 600; font-size: 16px; } +.look-head { margin: 4px 0 0; color: var(--dim); max-width: 75ch; } +.look-empty { background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s4); } + +/* --- the Progress tab: the step map (live.js) --- */ +.progress-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s4); padding-top: var(--s3); } +.map-wrap { max-height: 70vh; } +.map-wrap { overflow: auto; min-width: 0; background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); padding: var(--s3); } +.side { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--s3); align-items: start; } +.side > * { min-width: 0; } +#plugin-panels { display: contents; } +.legend { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; color: var(--dim); font-size: 12px; } +.legend .swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 4px; vertical-align: -2px; border: 1px solid var(--rule); } +.edges line { stroke: var(--rule); stroke-width: 1.2; } .box { cursor: pointer; } -.box rect { stroke: var(--line); stroke-width: 1; } -.box text { fill: var(--fg); font-size: 12px; pointer-events: none; } -.box.selected rect { stroke: var(--accent); stroke-width: 2; } +.box rect { stroke: var(--rule); stroke-width: 1; } +.box text { fill: var(--text); font-size: 12px; pointer-events: none; } +.box.selected rect { stroke: var(--focus); stroke-width: 2; } .box:focus { outline: none; } -.box:focus-visible rect { stroke: var(--accent); stroke-width: 3; stroke-dasharray: 4 2; } -.found li[role=button] { cursor: pointer; } -.found li[role=button]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.s-waiting rect, .s-waiting .swatch { fill: var(--waiting); background: var(--waiting); } -.s-running rect, .s-running .swatch { fill: var(--running); background: var(--running); } -.s-done rect, .s-done .swatch { fill: var(--done); background: var(--done); } -.s-failed rect, .s-failed .swatch { fill: var(--failed); background: var(--failed); } -.s-skipped rect, .s-skipped .swatch { fill: var(--skipped); background: var(--skipped); } -.s-notfinished rect, .s-notfinished .swatch { fill: var(--notfinished); background: var(--notfinished); } -.s-notplanned rect, .s-notplanned .swatch { fill: var(--notplanned); background: var(--notplanned); } -.s-skipped text, .s-notplanned text { fill: var(--muted); } +.box:focus-visible rect { stroke: var(--focus); stroke-width: 3; stroke-dasharray: 4 2; } +.s-waiting rect, .s-waiting .swatch { fill: var(--card2); background: var(--card2); } +.s-running rect, .s-running .swatch { fill: #3a6aa6; background: #3a6aa6; } +.s-done rect, .s-done .swatch { fill: #2b3a4f; background: #2b3a4f; } +.s-failed rect, .s-failed .swatch { fill: var(--fail); background: var(--fail); } +.s-skipped rect, .s-skipped .swatch { fill: #2b2838; background: #2b2838; } +.s-notfinished rect, .s-notfinished .swatch { fill: #4a3c22; background: #4a3c22; } +.s-notplanned rect, .s-notplanned .swatch { fill: var(--bg); background: var(--bg); } +.s-skipped text, .s-notplanned text { fill: var(--dim); } +/* Light theme: pale state fills under the dark label text, so every step name stays readable. */ +@media (prefers-color-scheme: light) { + .s-running rect, .s-running .swatch { fill: #b9d0ef; background: #b9d0ef; } + .s-done rect, .s-done .swatch { fill: #d3dbe6; background: #d3dbe6; } + .s-skipped rect, .s-skipped .swatch { fill: #e6e3ef; background: #e6e3ef; } + .s-notfinished rect, .s-notfinished .swatch { fill: #f1e0bd; background: #f1e0bd; } + .s-failed text { fill: #fff; } +} .s-running rect { animation: pulse 2s ease-in-out infinite; } @keyframes pulse { 50% { opacity: .7; } } -dl { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; margin: 0 0 8px; } -dt { color: var(--muted); } -dd { margin: 0; } +.recheck-mark { fill: var(--low); } +dl { margin: 0 0 8px; } +#detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; } +#detail dt { color: var(--dim); } +#detail dd { margin: 0; } table.tasks { border-collapse: collapse; width: 100%; font-size: 12px; margin-bottom: 8px; } -table.tasks th, table.tasks td { border-bottom: 1px solid var(--line); padding: 2px 4px; text-align: right; } +table.tasks th, table.tasks td { border-bottom: 1px solid var(--rule); padding: 2px 4px; text-align: right; } table.tasks th:first-child, table.tasks td:first-child { text-align: left; } .found { list-style: none; margin: 0; padding: 0; } -.found li { padding: 6px 0; border-bottom: 1px solid var(--line); cursor: pointer; } -.found .step { display: inline-block; min-width: 30px; color: var(--muted); } -.tone-attention { border-left: 3px solid var(--attention); padding-left: 6px; } -.tone-info { border-left: 3px solid var(--line); padding-left: 6px; } -.headline { margin: 6px 0; } -.replay { display: flex; gap: 10px; align-items: center; padding: 8px 18px; border-bottom: 1px solid var(--line); } -.replay[hidden] { display: none; } -.replay input[type=range] { flex: 1; } -button { background: var(--waiting); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 5px 12px; cursor: pointer; } +.found li { padding: 6px 0; border-bottom: 1px solid var(--rule); cursor: pointer; } +.found li[role=button]:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.found .step { display: inline-block; min-width: 30px; color: var(--dim); } +.tone-attention { border-left: 3px solid var(--attn); padding-left: 6px; } +.tone-info { border-left: 3px solid var(--rule); padding-left: 6px; } +.plugin ul.plain { padding-left: 1.1em; } +.replay { display: flex; gap: 10px; align-items: center; padding: 8px var(--s5); border-bottom: 1px solid var(--rule); max-width: 1120px; margin: 0 auto; } +.replay input[type=range] { flex: 1; accent-color: var(--info); } +#replay-time { color: var(--dim); font-size: 13px; } +.family-table { border-collapse: collapse; width: 100%; font-size: 13px; } +.family-table td, .family-table th { border-bottom: 1px solid var(--rule); padding: 3px 6px; text-align: left; } + +/* --- home: people by family --- */ +.home, .family { max-width: 1120px; margin: 0 auto; padding: var(--s5) var(--s5) var(--s7); } +.family-block { margin-bottom: var(--s6); } +.family-head { position: sticky; top: 52px; z-index: 5; display: flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap; + padding: var(--s2) 0; background: var(--bg); border-bottom: 1px solid var(--rule); margin-bottom: var(--s3); } +.family-head h2 { font-family: var(--serif); font-size: 24px; font-weight: 500; } +.family-head h2 a { color: var(--text); text-decoration: none; } +.family-head .open { margin-left: auto; font-size: 14px; } +.people { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--s3); } +.person-card .person-link { display: flex; gap: var(--s3); align-items: flex-start; height: 100%; padding: var(--s3) var(--s4); + background: var(--card); border: 1px solid var(--rule); border-radius: var(--r-card); color: var(--text); text-decoration: none; } +.person-card:not(.has-run) .person-link { background: transparent; border-style: dashed; color: var(--dim); } +.person-card a.person-link:hover { border-color: var(--dim); } +.person-card.running .person-link { border-color: color-mix(in srgb, var(--info) 60%, var(--rule)); } +.person-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } +.person-name { font-weight: 600; font-size: 16px; overflow-wrap: anywhere; } +.person-place { color: var(--dim); font-size: 13px; } +.person-state { font-size: 13px; color: var(--dim); } +.person-look { font-size: 13px; color: var(--dim); } +.person-look.attn { color: var(--attn); font-weight: 600; } +.person-look.attn::before { content: "\25b2 "; font-size: 10px; } +.bar { display: block; height: 5px; border-radius: 3px; background: var(--rule); margin: 6px 0 2px; overflow: hidden; } +.bar-fill { --p: 0; display: block; height: 100%; width: calc(var(--p) * 100%); background: var(--info); } ul.runs { list-style: none; padding: 0; } -ul.runs li { padding: 6px 0; border-bottom: 1px solid var(--line); } -.kind { color: var(--muted); font-size: 12px; margin-left: 6px; } -body.login main { justify-content: center; padding-top: 12vh; } +ul.runs li { padding: 6px 0; border-bottom: 1px solid var(--rule); } +.kind { color: var(--dim); font-size: 12px; margin-left: 6px; } + +/* --- a family --- */ +.family-page { display: grid; gap: var(--s4); } +.family-top { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--s4); align-items: start; } +.family-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s4); } +.pedigree-figure { margin: 0; } +.pedigree-svg { width: 100%; height: auto; max-height: 340px; display: block; margin: 0 auto; } +.ped-lines line { stroke: var(--dim); stroke-width: 2; } +.ped-shape { fill: var(--card); stroke: var(--text); stroke-width: 2; } +.ped-shape.filled { fill: var(--text); } +.ped-person.link .ped-shape { stroke: var(--info); } +.ped-person.link:hover .ped-shape, .ped-person.link:focus .ped-shape { stroke-width: 4; } +.ped-name { fill: var(--text); font: 13px var(--sans); } +.ped-person.link .ped-name { fill: var(--info); text-decoration: underline; } +.ped-flag { fill: var(--attn); font: 700 12px var(--sans); } +.pedigree-figure figcaption { font-size: 13px; margin-top: var(--s2); } +.pedigree-image summary { cursor: pointer; color: var(--info); font-size: 13px; } +img.pedigree, img.segments { max-width: 100%; height: auto; display: block; margin-top: var(--s2); border-radius: 4px; } +.members .people { grid-template-columns: 1fr; } +.analyses { margin: var(--s3) 0 0; color: var(--dim); font-size: 14px; } +.analyses .label { color: var(--text); } +.plan-item { color: var(--text); } +.plan-item.d-skipped, .plan-item.d-waiting { color: var(--dim); } +.couple-line { max-width: 75ch; } +.couples .caveat-lead { font-size: 13px; color: var(--dim); max-width: 75ch; } +details[data-child] { border-top: 1px solid var(--rule); padding: var(--s2) 0; } +details[data-child] summary { cursor: pointer; } + +/* --- login --- */ +body.login main { display: flex; justify-content: center; padding-top: 12vh; } body.login form { width: 320px; display: flex; flex-direction: column; gap: 8px; } -body.login h1 { font-size: 18px; margin: 0 0 4px; } -body.login .msg { color: var(--attention); min-height: 1em; margin: 0; } -input[type=password] { background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 7px; } -@media (max-width: 900px) { main { flex-direction: column; } aside { flex: 1 1 auto; width: 100%; } } +body.login h1 { font-family: var(--serif); font-size: 24px; font-weight: 500; margin: 0 0 4px; } +body.login .msg { color: var(--attn); min-height: 1em; margin: 0; } +input[type=password] { font: inherit; background: var(--card); color: var(--text); border: 1px solid var(--rule); border-radius: 6px; padding: 8px; } + +/* --- narrow screens: one column, the rail becomes a row of tabs that scrolls sideways --- */ +@media (max-width: 900px) { + .layout { grid-template-columns: minmax(0, 1fr); padding: 0 var(--s3) var(--s6); gap: var(--s2); } + .rail { position: sticky; top: 52px; z-index: 10; flex-direction: row; overflow-x: auto; gap: 4px; padding: var(--s2) 0; + background: var(--bg); scrollbar-width: none; } + .rail [role=tab] { flex: none; border-left: 0; border-bottom: 3px solid transparent; border-radius: 6px 6px 0 0; padding: 6px 10px; } + .rail [role=tab][aria-selected=true] { border-bottom-color: var(--info); } + .rail [data-tab=leaves] { margin-top: 0; } + .progress-grid, .family-top, .family-grid { grid-template-columns: minmax(0, 1fr); } + .person-head { padding: var(--s4) var(--s3) var(--s2); } + .person-head h1 { font-size: 24px; } + .top { padding: 8px var(--s3); gap: var(--s3); } + .links #home-link { display: none; } + .home, .family { padding: var(--s4) var(--s3) var(--s6); } + .card { padding: var(--s3) var(--s4); } + .result.attn { padding-left: calc(var(--s4) - 3px); } +} +@media (max-width: 600px) { + .facts { grid-template-columns: minmax(0, 1fr); } + .search-slot { width: auto; flex: 1; min-width: 0; } + .crumbs { display: none; } + table.rows thead { display: none; } + table.rows, table.rows tbody, table.rows tr, table.rows td { display: block; width: 100%; } + table.rows tr { padding: 6px 0; border-bottom: 1px solid var(--rule); } + table.rows tbody tr:nth-child(even) td { background: none; } + table.rows td { padding: 1px 0; } + table.rows td::before { content: attr(data-label) ": "; color: var(--dim); font-size: 13px; } + .look-list a { flex-direction: column; gap: 0; } + .people { grid-template-columns: minmax(0, 1fr); } +} +@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } diff --git a/assets/live/live.js b/assets/live/live.js index 29c3332d..efadf6fd 100644 --- a/assets/live/live.js +++ b/assets/live/live.js @@ -1,7 +1,10 @@ -// live.js: the live page of one run (assets/live/index.html, served by bin/owngenome_live.py). -// One data source feeds the page: in live mode state.json plus the run's event stream; with -// a frames.json of recorded states played on a timer. No outside -// request, no inline script, no eval. Plugins call window.PGP.register({name, onState, onEvent, panel}). +// live.js: the run's progress on a person's page (assets/live/index.html, served by bin/owngenome_live.py): +// the step map, the step detail, "Found so far", the event stream and the replay, shown in the +// Progress tab. One data source feeds the page: in live mode state.json plus the run's event stream; +// with a frames.json of recorded states played on a timer. person.js draws +// the results from the same states. No outside request, no inline script, no eval. Plugins call +// window.PGP.register({name, onState, onEvent, panel, slot}); a panel goes into the element whose id +// is slot when the page has one (the search box in the header), else into the Progress tab. (function () { 'use strict'; var root = document.documentElement; @@ -278,7 +281,8 @@ plugins.push(p); if (p.panel) { var box = el('section', { 'class': 'card plugin', 'data-plugin': p.name || '' }); - document.getElementById('plugin-panels').appendChild(box); + var slot = (p.slot && document.getElementById(p.slot)) || document.getElementById('plugin-panels'); + slot.appendChild(box); try { p.panel(box); } catch (e) { console.error(p.name, e); } } if (last && p.onState) { p.onState(last); } diff --git a/assets/live/person.js b/assets/live/person.js new file mode 100644 index 00000000..6433f7d1 --- /dev/null +++ b/assets/live/person.js @@ -0,0 +1,354 @@ +// person.js: a person's results on their page (assets/live/index.html), from results.json +// (bin/live_results.py, served by bin/owngenome_live_results.py) and the run's states (live.js). +// The rail holds the report's seven chapter questions, "Look first" (every card worth a look) and +// "What this leaves out"; Progress is live.js's step map. The tab lives in the URL fragment only +// (#q/, #s/
, #progress), which the browser never sends, so no tab or search reaches +// a server log. A card shows its results once one of its steps is done in the current state, so live +// and replay draw the same way and a replay reveals the results as the run goes. +(function () { + 'use strict'; + if (!window.PGP || !window.PGPUI) { return; } + var U = window.PGPUI, el = U.el, add = U.add; + var root = document.documentElement; + var replay = root.getAttribute('data-mode') === 'replay'; + var results = null, state = null, fetchedFor = null, current = null, chosen = false, drawnKey = null; + var rail = document.getElementById('rail'); + var panel = document.getElementById('results'); + var progress = document.getElementById('progress'); + var SHORT = { quality: 'Trust', clinical: 'Doctor', carrier: 'Child', medicines: 'Medicines', ancestry: 'Ancestors', + traits: 'Traits', other: 'Other' }; + var LOOK_EMPTY = 'Nothing on this list. That is information, not a clean bill: each chapter’s caveats and ' + + '“What this leaves out” still apply.'; + + function stepsBy() { + var by = {}; + ((state && state.steps) || []).forEach(function (s) { by[s.step] = s; }); + return by; + } + function doneSet() { + return ((state && state.steps) || []).filter(function (s) { return s.state === 'done'; }) + .map(function (s) { return s.step; }).join(','); + } + function anyDone() { return doneSet() !== ''; } + // What a card can show at this moment: 'show', 'waiting' (a step still to come), 'notrun' (none planned) + // or 'empty' (its steps ended and wrote nothing for it). + function gate(c) { + var by = stepsBy(), steps = c.steps || []; + var known = steps.filter(function (s) { return by[s]; }); + if (known.some(function (s) { return by[s].state === 'done'; })) { return c.state === 'missing' ? 'empty' : 'show'; } + if (known.some(function (s) { return ['waiting', 'running'].indexOf(by[s].state) >= 0; })) { return 'waiting'; } + if (known.some(function (s) { return by[s].state === 'failed'; })) { return 'failed'; } + if (!known.length && c.state !== 'missing' && !replay) { return 'show'; } + return 'notrun'; + } + function waitingText(c) { + var by = stepsBy(); + var s = (c.steps || []).filter(function (x) { return by[x] && by[x].state !== 'done'; })[0] || (c.steps || [])[0]; + return 'Waiting for step ' + s + (by[s] ? ' (' + by[s].label + ')' : '') + '.'; + } + function cards() { + var out = []; + ((results && results.chapters) || []).forEach(function (ch) { + ch.sections.forEach(function (c) { out.push({ ch: ch, c: c }); }); + }); + return out; + } + function attentionNow(c) { return c.attention && gate(c) === 'show'; } + + // --- the route -------------------------------------------------------------------------------- + function parse() { + var h; + try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return null; } // a broken fragment + if (h === 'progress') { return { tab: 'progress' }; } + if (h.indexOf('q/') === 0) { return { tab: h.slice(2) }; } + if (h.indexOf('s/') === 0) { return { tab: 'section', id: h.slice(2) }; } + if (h && cards().some(function (x) { return x.c.id === h; })) { return { tab: 'section', id: h }; } // an old #clinvar link + return null; + } + function chapterOf(id) { + var hit = cards().filter(function (x) { return x.c.id === id; })[0]; + return hit ? hit.ch.key : null; + } + function defaultTab() { return results && results.chapters && results.chapters.length && anyDone() ? 'look' : 'progress'; } + + function route(focus) { + var r = parse() || { tab: defaultTab() }; + var tab = r.tab === 'section' ? (chapterOf(r.id) || 'look') : r.tab; + var valid = ['progress', 'look', 'leaves'].concat(((results && results.chapters) || []).map(function (c) { return c.key; })); + if (valid.indexOf(tab) < 0) { tab = defaultTab(); } + show(tab); + Array.prototype.forEach.call(rail.querySelectorAll('[role=tab]'), function (t) { + var on = t.getAttribute('data-tab') === tab; + t.setAttribute('aria-selected', on ? 'true' : 'false'); + t.tabIndex = on ? 0 : -1; + }); + if (r.tab === 'section') { + var card = document.getElementById('card-' + r.id); + if (card) { + var d = card.querySelector('details.caveat-full'); + if (d && focus !== false) { card.classList.add('target'); } + card.scrollIntoView({ block: 'start' }); + card.focus({ preventScroll: true }); + } + } else if (window.location.hash === '#progress') { + // #progress is also the section's id, so the browser jumps to it: keep the head and the rail in view. + setTimeout(function () { window.scrollTo(0, 0); }, 0); + } + } + function go(hash) { + if (window.location.hash === hash) { route(); } else { window.location.hash = hash; } + } + window.addEventListener('hashchange', function () { chosen = true; route(); }); + + // --- the rail --------------------------------------------------------------------------------- + function drawRail() { + rail.textContent = ''; + function tab(key, label, n, hash) { + var a = el('a', { role: 'tab', href: hash, id: 'tab-' + key, 'data-tab': key, 'aria-controls': key === 'progress' ? 'progress' : 'results' }); + add(a, el('span', { 'class': 'tab-label' }, label)); + if (n) { add(a, el('span', { 'class': 'count', 'aria-label': n + ' worth a look' }, String(n))); } + add(rail, a); + return a; + } + if (results && results.chapters && results.chapters.length) { + var all = cards().filter(function (x) { return attentionNow(x.c); }).length; + tab('look', 'Look first', all, '#q/look'); + results.chapters.forEach(function (ch) { + var n = ch.sections.filter(attentionNow).length; + var t = tab(ch.key, SHORT[ch.key] || ch.title, n, '#q/' + ch.key); + t.setAttribute('title', ch.question); + }); + tab('leaves', 'Leaves out', 0, '#q/leaves'); + } + tab('progress', 'Progress', 0, '#progress'); + } + U.tablist(rail, function (t) { go(t.getAttribute('href')); }); + + // --- a card ----------------------------------------------------------------------------------- + function badge(text, cls, symbol) { + var b = el('span', { 'class': 'chip ' + cls }); + if (symbol) { add(b, el('span', { 'class': 'sym', 'aria-hidden': 'true' }, symbol)); } + add(b, text); + return b; + } + function recheckBadges(c) { + var r = state && state.plugins && state.plugins.recheck; + var out = []; + (c.steps || []).forEach(function (s) { + ((r && r.steps && r.steps[s]) || []).forEach(function (t) { out.push(badge(t, 'chip-recheck', '↻')); }); + }); + return out; + } + function card(c, ch) { + var g = gate(c); + var cls = 'card result g-' + g + (attentionNow(c) ? ' attn' : ''); + var art = el('article', { 'class': cls, id: 'card-' + c.id, 'data-section': c.id, 'data-step': (c.steps || []).join(' '), + 'data-state': c.state, tabindex: '-1', 'aria-labelledby': 'h-' + c.id }); + var head = el('header', { 'class': 'card-head' }); + var h = el('h3', { id: 'h-' + c.id }); + add(h, el('a', { href: '#s/' + c.id, 'class': 'self' }, c.title)); + add(head, h); + var chips = el('div', { 'class': 'chips' }); + if (g === 'show') { + add(chips, attentionNow(c) ? badge('worth a look', 'chip-attn', '▲') : badge('for information', 'chip-info', '○')); + } + recheckBadges(c).forEach(function (b) { add(chips, b); }); + add(head, chips); + add(art, head); + if (g === 'waiting') { + add(art, el('p', { 'class': 'state-text' }, waitingText(c))); + } else if (g === 'notrun') { + add(art, el('p', { 'class': 'state-text' }, 'Not run in this run (step ' + (c.steps || []).join(', ') + ').')); + } else if (g === 'failed') { + add(art, el('p', { 'class': 'state-text' }, 'Step ' + (c.steps || []).join(', ') + ' failed: the Progress tab says why.')); + } else if (g === 'empty') { + add(art, el('p', { 'class': 'state-text' }, 'Its step ended without a result for this section.')); + } else { + (c.notes || []).forEach(function (n) { add(art, el('p', { 'class': 'note-stale' }, n)); }); + if (c.headline) { add(art, el('p', { 'class': 'headline' }, c.headline)); } + add(art, U.facts(c.facts)); + Object.keys((results && results.charts) || {}).forEach(function (chart) { + if (results.charts[chart] === c.id) { add(art, el('div', { 'class': 'chart-slot', 'data-chart-slot': chart })); } + }); + if (c.image) { add(art, el('img', { 'class': 'plot', src: c.image.src, alt: c.image.alt })); } + var tb = U.table(c.table); + if (tb) { add(art, tb); } else if (c.empty_text) { add(art, el('p', { 'class': 'empty' }, c.empty_text)); } + if (c.lines && c.lines.length) { + var ul = el('ul', { 'class': 'lines' }); + c.lines.forEach(function (l) { add(ul, el('li', {}, l)); }); + add(art, ul); + } + if (c.hidden) { + var d = el('details', { 'class': 'behind' }); + add(d, el('summary', {}, c.hidden.summary), el('p', {}, c.hidden.text)); + add(art, d); + } + if (c.explore && c.explore.length) { + var p = el('p', { 'class': 'explore' }, 'Explore the branch (opens a website; the address carries the haplogroup): '); + c.explore.forEach(function (x, i) { + if (i) { add(p, ', '); } + add(p, el('a', { href: x.url, rel: 'noreferrer noopener', target: '_blank' }, x.name)); + }); + add(art, p); + } + } + add(art, U.caveat(c.caveat)); + var foot = el('p', { 'class': 'card-links' }); + add(foot, el('a', { href: 'report.html#' + encodeURIComponent(c.anchor), 'class': 'report' }, 'Open in the report')); + if (g === 'show' && c.viewer && c.viewer.length) { + add(foot, el('a', { href: 'viewer/#' + c.viewer[0], 'class': 'viewer' }, 'See the reads at ' + c.viewer[0])); + } + (g === 'show' ? c.links || [] : []).forEach(function (f) { + add(foot, el('a', { href: f.split('/').map(encodeURIComponent).join('/') }, 'Open ' + f)); + }); + add(art, foot); + return art; + } + + // --- the views -------------------------------------------------------------------------------- + function heading(text, sub) { + var h = el('header', { 'class': 'view-head' }); + add(h, el('h2', { 'class': 'question' }, text)); + if (sub) { add(h, el('p', { 'class': 'dim' }, sub)); } + return h; + } + function lookFirst() { + var box = el('div', { id: 'look-first', 'class': 'view' }); + add(box, heading('Look at these first', 'The cards the report itself flags, in chapter order. A card not listed is for information; its caveats still apply.')); + var items = cards().filter(function (x) { return attentionNow(x.c); }); + if (!items.length) { + add(box, el('p', { 'class': 'empty look-empty' }, LOOK_EMPTY)); + } else { + var ol = el('ol', { 'class': 'look-list' }); + items.forEach(function (x) { + var li = el('li', {}); + var a = el('a', { href: '#s/' + x.c.id }); + add(a, el('span', { 'class': 'look-ch' }, SHORT[x.ch.key] || x.ch.title), el('span', { 'class': 'look-title' }, x.c.title)); + add(li, a); + if (x.c.headline) { add(li, el('p', { 'class': 'look-head' }, x.c.headline)); } + add(ol, li); + }); + add(box, ol); + } + var waiting = cards().filter(function (x) { return gate(x.c) === 'waiting'; }).length; + if (waiting) { add(box, el('p', { 'class': 'dim' }, (waiting === 1 ? '1 card is' : waiting + ' cards are') + ' still waiting for its step.')); } + return box; + } + function chapter(ch) { + var box = el('div', { 'class': 'view chapter', 'data-chapter': ch.key }); + add(box, heading(ch.question, ch.title)); + var grid = el('div', { 'class': 'cards' }); + ch.sections.forEach(function (c) { add(grid, card(c, ch)); }); + add(box, grid); + return box; + } + function leaves() { + var box = el('div', { 'class': 'view' }); + add(box, heading('What this leaves out', 'What no step looks at, and the steps that did not run.')); + function list(title, items) { + var s = el('section', { 'class': 'card' }); + add(s, el('h3', {}, title)); + var ul = el('ul', { 'class': 'lines' }); + (items.length ? items : ['None.']).forEach(function (t) { add(ul, el('li', {}, t)); }); + add(s, ul); + add(box, s); + } + list('No step looks at these', (results && results.not_assessed) || []); + list('Steps that did not run', (results && results.not_run) || []); + return box; + } + function show(tab) { + current = tab; + var isProgress = tab === 'progress'; + progress.hidden = !isProgress; + panel.hidden = isProgress; + root.setAttribute('data-pgp-tab', tab); + if (isProgress) { return; } + panel.textContent = ''; + if (results && results.note && !(results.chapters || []).length) { + add(panel, el('p', { 'class': 'empty' }, results.note)); + return; + } + if (tab === 'look') { add(panel, lookFirst()); } else if (tab === 'leaves') { add(panel, leaves()); } else { + var ch = ((results && results.chapters) || []).filter(function (c) { return c.key === tab; })[0]; + if (ch) { add(panel, chapter(ch)); } + } + root.setAttribute('data-pgp-results', String(panel.querySelectorAll('article.result').length)); + document.dispatchEvent(new CustomEvent('pgp:cards')); + } + + // --- the header ------------------------------------------------------------------------------- + function header() { + var p = (results && results.person) || {}; + var slot = document.getElementById('avatar-slot'); + var planned = ((state && state.steps) || []).filter(function (s) { return s.state !== 'not planned'; }); + var done = planned.filter(function (s) { return s.state === 'done'; }).length; + slot.textContent = ''; + add(slot, U.avatar(p.inferred_sex || (state && state.run && state.run.declared_sex), true, planned.length ? done / planned.length : 0)); + var line = document.getElementById('person-line'); + line.textContent = ''; + var bits = []; + if (p.inferred_sex) { bits.push('Sex from the reads: ' + p.inferred_sex + (p.declared_sex ? ', declared ' + p.declared_sex : '')); } + if (bits.length) { add(line, bits.join('. ') + '. '); } + var fam = state && state.plugins && state.plugins.family; + var crumbs = document.getElementById('crumbs'); + var name = document.getElementById('run-name'); + crumbs.textContent = ''; + ((fam && fam.kind === 'member' && fam.families) || []).forEach(function (f, i) { + if (i === 0) { add(line, 'In family '); } else { add(line, ', '); } + add(line, el('a', { href: f.url }, f.name)); + add(crumbs, el('a', { href: f.url }, f.name), el('span', { 'class': 'sep', 'aria-hidden': 'true' }, '/')); + }); + add(crumbs, name); + // The strip: one cell per planned step, filled when done, striped while it runs. + var strip = document.getElementById('strip'); + strip.textContent = ''; + planned.forEach(function (s) { + add(strip, el('span', { 'class': 'cell st-' + s.state.replace(/\s+/g, '-'), title: s.step + ' ' + s.label + ': ' + s.state })); + }); + var txt = document.getElementById('strip-text'); + var running = planned.filter(function (s) { return s.state === 'running'; }).length; + var failed = planned.filter(function (s) { return s.state === 'failed'; }).length; + var run = (state && state.run) || {}; + var t = done + ' of ' + planned.length + ' steps done'; + if (run.state === 'finished') { t = 'Finished' + (run.finished_utc ? ' ' + U.day(run.finished_utc) : '') + ': ' + t; } + else if (running) { t = 'Running: ' + t + ', ' + running + ' running now'; } + if (failed) { t += ', ' + failed + ' failed'; } + var rc = state && state.plugins && state.plugins.recheck; + if (rc && rc.checked_utc) { t += '. Databases re-checked ' + U.day(rc.checked_utc); } + txt.textContent = t + '.'; + } + + // --- data --------------------------------------------------------------------------------------- + function load() { + var key = replay ? 'replay' : doneSet() + '|' + ((state && state.results_stamp) || ''); + if (key === fetchedFor) { return; } + fetchedFor = key; + U.getJSON('results.json').then(function (r) { + if (key !== fetchedFor) { return; } + if (!(r && (r.chapters || r.note))) { fetchedFor = null; return; } // tried again on the next state + results = r; + redraw(true); + }).catch(function () { if (key === fetchedFor) { fetchedFor = null; } }); + } + function redraw(force) { + var k = JSON.stringify([doneSet(), state && state.run && state.run.state, !!results, + state && state.plugins && state.plugins.recheck && state.plugins.recheck.mtime]); + if (!force && k === drawnKey) { return; } + drawnKey = k; + header(); + var keep = document.activeElement && rail.contains(document.activeElement) ? document.activeElement.getAttribute('data-tab') : null; + drawRail(); + if (!chosen && !parse()) { show(defaultTab()); route(false); } else { route(false); } + if (keep) { var t = rail.querySelector('[data-tab="' + keep + '"]'); if (t) { t.focus(); } } + } + + window.PGP.register({ + name: 'person', + onState: function (s) { + state = s; + if (!replay || !results) { load(); } + redraw(false); + } + }); +}()); diff --git a/assets/live/plugins/charts.js b/assets/live/plugins/charts.js index 0bc0181e..8fdebf9a 100644 --- a/assets/live/plugins/charts.js +++ b/assets/live/plugins/charts.js @@ -2,12 +2,13 @@ // When state.json says the run's _charts.json exists, it is fetched from the run // folder (same origin, nothing else) and each chart's SVG is shown with its caveat. A click on // a mark opens a panel with the mark's detail, the chart's caveat, a link to the reads in the -// genome viewer (viewer/#) and a link to the section of the report. No inline script, -// no style attribute, no eval: the SVG is parsed as XML and holds no script. +// genome viewer (viewer/#) and a link to the section of the report. On a person's page each +// chart goes into its card (a [data-chart-slot=] person.js draws), else into this plugin's +// panel. No inline script, no style attribute, no eval: the SVG is parsed as XML and holds no script. (function () { 'use strict'; if (!window.PGP) { return; } - var list = null, side = null, shownKey = null; + var list = null, side = null, shownKey = null, figs = []; function el(tag, attrs, text) { var e = document.createElement(tag); @@ -26,7 +27,8 @@ box.appendChild(list); } - function openMark(sp, m) { + function openMark(sp, m, fig) { + if (fig && fig.parentNode) { fig.parentNode.insertBefore(side, fig.nextSibling); } side.textContent = ''; side.hidden = false; side.setAttribute('data-mark', m.id); @@ -56,14 +58,26 @@ a.addEventListener('click', function (e) { e.preventDefault(); var m = byId[a.getAttribute('data-mark')]; - if (m) { openMark(sp, m); } + if (m) { openMark(sp, m, fig); } }); }); return fig; } + // Each figure into its card's slot when the page has one, else into the panel. + function place() { + figs.forEach(function (fig) { + var slot = document.querySelector('[data-chart-slot="' + fig.getAttribute('data-chart') + '"]'); + var to = slot || list; + if (fig.parentNode !== to) { to.appendChild(fig); } + }); + } + document.addEventListener('pgp:cards', function () { if (list) { place(); } }); + function clear() { shownKey = null; + figs.forEach(function (f) { if (f.parentNode) { f.parentNode.removeChild(f); } }); + figs = []; list.textContent = ''; list.appendChild(el('p', { 'class': 'muted' }, 'The charts appear when the report step has written them.')); side.hidden = true; @@ -73,8 +87,10 @@ function draw(data) { if (!data || !data.charts) { return; } + figs.forEach(function (f) { if (f.parentNode) { f.parentNode.removeChild(f); } }); list.textContent = ''; - data.charts.forEach(function (sp) { list.appendChild(chart(sp)); }); + figs = data.charts.map(chart); + place(); document.documentElement.setAttribute('data-pgp-charts', String(data.charts.length)); } diff --git a/assets/live/plugins/family.js b/assets/live/plugins/family.js index e9a936c7..8875f183 100644 --- a/assets/live/plugins/family.js +++ b/assets/live/plugins/family.js @@ -1,9 +1,10 @@ -// family.js: the family card of the live page (bin/owngenome_live_family.py fills plugins.family). -// On a family's page: the pedigree, each child's chromosome map, the relationship check, the -// shared segments and the couple carrier screen, as steps 38 and 39 wrote them. On a sample's -// page: a link to its family. Text only through textContent; images from this server only. +// family.js: the family card of a person's page (bin/owngenome_live_family.py fills plugins.family): +// a link to each family the person is in. A family's own page is family.html (family-page.js); this +// card still draws a family's state when a page with live.js gets one. Text only through +// textContent; images from this server only. (function () { 'use strict'; + if (!window.PGP) { return; } // family.html draws a family's own page (family-page.js) without live.js var box = null, shownKey = null; function el(tag, attrs, text) { diff --git a/assets/live/plugins/ops.js b/assets/live/plugins/ops.js index e11f8667..b399503f 100644 --- a/assets/live/plugins/ops.js +++ b/assets/live/plugins/ops.js @@ -70,6 +70,7 @@ }); } + if (!window.PGP) { return; } // family.html loads the plugins without live.js window.PGP.register({ name: 'ops', panel: function (el_) { box = el_; draw(); }, diff --git a/assets/live/plugins/recheck.js b/assets/live/plugins/recheck.js index 1301e78a..19275e7e 100644 --- a/assets/live/plugins/recheck.js +++ b/assets/live/plugins/recheck.js @@ -1,7 +1,8 @@ // recheck.js: the database re-check on the live page (a plugin, bin/owngenome_live_recheck.py). // state.json's plugins.recheck holds a banner line and, per step, its badges ("newer ClinVar // available", "redone 2026-10-05"). The panel shows the banner and one line per marked step; on -// the step map a marked step gets a small dot with the badges as its tooltip. No inline script, +// the step map a marked step gets a small dot (coloured by live.css) with the badges as its +// tooltip. person.js puts the same badges in the header of each card of a marked step. No inline script, // no style attribute, no eval; nothing is fetched beyond state.json. (function () { 'use strict'; @@ -38,7 +39,6 @@ c.setAttribute('cx', String(x)); c.setAttribute('cy', String(y)); c.setAttribute('r', '4'); - c.setAttribute('fill', '#e0b000'); var t = document.createElementNS(NS, 'title'); t.textContent = badges.join('; '); c.appendChild(t); diff --git a/assets/live/plugins/search.js b/assets/live/plugins/search.js index 01e66372..6a96bb33 100644 --- a/assets/live/plugins/search.js +++ b/assets/live/plugins/search.js @@ -6,6 +6,7 @@ // the report and, for a mention with a locus, to the reads in the genome viewer. The query rule // is bin/results_index.py's query(): an rsID, a gene symbol (a prefix also lists the terms that // start with it), or a position, which the index cannot answer (scripts/owngenome-lookup.sh can). +// The box sits in the page header; on a person's page a hit opens its card (#s/
). // tests/test_results_index.py runs match() under node against the Python rule. (function (root) { 'use strict'; @@ -48,13 +49,13 @@ box.appendChild(el('h2', {}, 'Search the results')); var form = el('form', { role: 'search' }); form.addEventListener('submit', function (e) { e.preventDefault(); show(); }); - input = el('input', { type: 'search', 'aria-label': 'Gene symbol or rsID', placeholder: 'BRCA2 or rs429358', + input = el('input', { type: 'search', 'aria-label': 'Gene symbol or rsID', placeholder: 'Search arrives with the report', autocomplete: 'off', spellcheck: 'false' }); input.disabled = true; input.addEventListener('input', show); form.appendChild(input); box.appendChild(form); - status = el('p', { 'class': 'muted' }, 'The search opens when the report step has written its index.'); + status = el('p', { 'class': 'muted search-status' }, 'The search opens when the report step has written its index.'); box.appendChild(status); out = el('div', { 'class': 'search-results', 'aria-live': 'polite' }); box.appendChild(out); @@ -63,7 +64,8 @@ function item(it) { var li = el('li', { 'data-section': it.section }); var head = el('p', {}); - head.appendChild(el('a', { href: 'report.html#' + (it.anchor || ''), 'class': 'report' }, it.title)); + var card = document.getElementById('results') ? '#s/' + (it.anchor || it.section) : 'report.html#' + (it.anchor || ''); + head.appendChild(el('a', { href: card, 'class': 'report' }, it.title)); head.appendChild(document.createTextNode(' (step ' + it.step + ')' + (it.state === 'stale' ? ', stale: from an earlier run' : '') + ': ' + it.count + (it.count === 1 ? ' mention' : ' mentions'))); li.appendChild(head); @@ -124,7 +126,9 @@ if (!out || !c) { return; } if (!c.file) { // The file went away (step 24 deletes it before it writes the report again). - if (shownKey !== null) { shownKey = null; index = null; input.disabled = true; show(); } + if (shownKey !== null) { + shownKey = null; index = null; input.disabled = true; input.placeholder = 'Search arrives with the report'; show(); + } return; } var key = c.file + '@' + c.mtime; @@ -141,6 +145,7 @@ if (key !== shownKey || !data || !data.terms) { return; } index = data; input.disabled = false; + input.placeholder = 'Gene or rsID, like BRCA2'; status.textContent = Object.keys(data.terms).length + ' genes and rsIDs in these results. Typing searches them here, ' + 'in this browser.'; show(); @@ -148,5 +153,6 @@ .catch(function () { if (key === shownKey) { shownKey = null; } }); } - root.PGP.register({ name: 'search', panel: panel, onState: onState }); + // family.html loads the plugins without live.js: nothing to register there. + if (root && root.PGP) { root.PGP.register({ name: 'search', slot: 'search-slot', panel: panel, onState: onState }); } }(typeof window !== 'undefined' ? window : null)); diff --git a/assets/output_contract.tsv b/assets/output_contract.tsv index 5477c732..7b6ffbd1 100644 --- a/assets/output_contract.tsv +++ b/assets/output_contract.tsv @@ -52,52 +52,51 @@ file {family}/{sample}_dnapainter.csv 38 FAMILY_REPORT nextflow csv experimental file {family}/{sample}_inheritance.tsv.gz 38 FAMILY_REPORT nextflow tsv experimental - - file {family}/{sample}_segments.svg 38 FAMILY_REPORT nextflow svg experimental - - file {family}/{sample}_segments.tsv 38 FAMILY_REPORT nextflow tsv experimental - - -file {sample}/aligned/{sample}_read_count.tsv 02 ALIGN_MINIMAP2 nextflow tsv stable - - file {sample}/aligned/{sample}_sorted.bam 02 ALIGN_MINIMAP2 both bam stable bash - file {sample}/aligned/{sample}_sorted.bam.bai 02 ALIGN_MINIMAP2 both index stable bash - -file {sample}/aligned/{sample}_sorted.cram 34 CRAM_ARCHIVE nextflow cram experimental - - -file {sample}/aligned/{sample}_sorted.cram.crai 34 CRAM_ARCHIVE nextflow index experimental - - -file {sample}/aligned/{sample}_sorted.cram.flagstat 34 CRAM_ARCHIVE nextflow bin experimental - - -file {sample}/ancestry/{sample}_ancestry.tsv 25 PRS_SUMMARY nextflow tsv stable - - -file {sample}/ancestry_fine/{sample}_ancestry_fine.tsv 26b ANCESTRY_FINE_REPORT nextflow tsv experimental - - +file {sample}/aligned/{sample}_sorted.cram 34 CRAM_ARCHIVE both cram experimental - - +file {sample}/aligned/{sample}_sorted.cram.crai 34 CRAM_ARCHIVE both index experimental - - +file {sample}/aligned/{sample}_sorted.cram.flagstat 34 CRAM_ARCHIVE both bin experimental - - +file {sample}/ancestry/{sample}_ancestry.tsv 25 PRS_SUMMARY both tsv stable - - +file {sample}/ancestry_fine/{sample}_ancestry_fine.tsv 26b ANCESTRY_FINE_REPORT both tsv experimental - - file {sample}/ancestry_fine/{sample}_ancestry_fine.txt 26b ANCESTRY_FINE_REPORT nextflow text experimental - - -file {sample}/ancestry_fine/{sample}_painting.bed 26b ANCESTRY_FINE_REPORT nextflow bed experimental - - -file {sample}/ancestry_fine/{sample}_painting.png 26b ANCESTRY_FINE_REPORT nextflow png experimental - - -file {sample}/ancient/{sample}_ancient.tsv 44 ANCIENT_REPORT nextflow tsv experimental - - -file {sample}/ancient/{sample}_ancient.txt 44 ANCIENT_REPORT nextflow text experimental - - -file {sample}/ancient/{sample}_ancient_info.tsv 44 ANCIENT_REPORT nextflow tsv experimental - - -file {sample}/ancient/{sample}_ancient_pca.svg 44 ANCIENT_REPORT nextflow svg experimental - - -file {sample}/annotsv/{sample}_annotsv_input.vcf 05 ANNOTSV nextflow vcf stable - - -file {sample}/annotsv/{sample}_sv_annotated.tsv 05 ANNOTSV nextflow tsv stable - - -file {sample}/annotsv/{sample}_sv_class4_5.tsv 05 ANNOTSV nextflow tsv stable - - -file {sample}/annotsv/{sample}_sv_class4_5_checked.tsv 05 ANNOTSV nextflow tsv stable - - -file {sample}/archaic/{sample}_archaic.tsv 43 ARCHAIC_REPORT nextflow tsv experimental - - -file {sample}/archaic/{sample}_archaic.txt 43 ARCHAIC_REPORT nextflow text experimental - - -file {sample}/archaic/{sample}_archaic_segments.tsv 43 ARCHAIC_REPORT nextflow tsv experimental - - -file {sample}/bloodtype/{sample}_bloodtype.tsv 41 BLOODTYPE nextflow tsv experimental - - -file {sample}/bloodtype/{sample}_bloodtype_depth.tsv 41 BLOODTYPE_DEPTH nextflow tsv experimental - - -file {sample}/bloodtype/{sample}_bloodtype_evidence.tsv 41 BLOODTYPE nextflow tsv experimental - - -file {sample}/chip/{sample}_chip.tsv 49 CHIP_REPORT nextflow tsv experimental - - -file {sample}/chip/{sample}_chip_depth.tsv 49 CHIP_REPORT nextflow tsv experimental - - -file {sample}/chip/{sample}_chip_filtered.vcf.gz 49 CHIP_MUTECT2 nextflow vcf experimental - - +file {sample}/ancestry_fine/{sample}_painting.bed 26b ANCESTRY_FINE_REPORT both bed experimental - - +file {sample}/ancestry_fine/{sample}_painting.png 26b ANCESTRY_FINE_REPORT both png experimental - - +file {sample}/ancient/{sample}_ancient.tsv 44 ANCIENT_REPORT both tsv experimental - - +file {sample}/ancient/{sample}_ancient.txt 44 ANCIENT_REPORT both text experimental - - +file {sample}/ancient/{sample}_ancient_info.tsv 44 ANCIENT_REPORT both tsv experimental - - +file {sample}/ancient/{sample}_ancient_pca.svg 44 ANCIENT_REPORT both svg experimental - - +file {sample}/annotsv/{sample}_annotsv_input.vcf 05 ANNOTSV both vcf stable - - +file {sample}/annotsv/{sample}_sv_annotated.tsv 05 ANNOTSV both tsv stable - - +file {sample}/annotsv/{sample}_sv_class4_5.tsv 05 ANNOTSV both tsv stable - - +file {sample}/annotsv/{sample}_sv_class4_5_checked.tsv 05 ANNOTSV both tsv stable - - +file {sample}/archaic/{sample}_archaic.tsv 43 ARCHAIC_REPORT both tsv experimental - - +file {sample}/archaic/{sample}_archaic.txt 43 ARCHAIC_REPORT both text experimental - - +file {sample}/archaic/{sample}_archaic_segments.tsv 43 ARCHAIC_REPORT both tsv experimental - - +file {sample}/bloodtype/{sample}_bloodtype.tsv 41 BLOODTYPE both tsv experimental - - +file {sample}/bloodtype/{sample}_bloodtype_depth.tsv 41 BLOODTYPE_DEPTH both tsv experimental - - +file {sample}/bloodtype/{sample}_bloodtype_evidence.tsv 41 BLOODTYPE both tsv experimental - - +file {sample}/chip/{sample}_chip.tsv 49 CHIP_REPORT both tsv experimental - - +file {sample}/chip/{sample}_chip_depth.tsv 49 CHIP_REPORT both tsv experimental - - +file {sample}/chip/{sample}_chip_filtered.vcf.gz 49 CHIP_MUTECT2 both vcf experimental - - file {sample}/clinical/{sample}_clinical.vcf.gz 23 CLINICAL_FILTER both vcf stable bash - file {sample}/clinical/{sample}_clinical.vcf.gz.tbi 23 CLINICAL_FILTER nextflow index stable - - -file {sample}/clinical/{sample}_clinical_summary.tsv 23 CLINICAL_FILTER nextflow tsv stable - - -file {sample}/clinvar/{sample}_clinvar_cdna_check.tsv 06 CLINVAR_CDNA_CHECK nextflow tsv stable - - -file {sample}/clinvar/{sample}_clinvar_cdna_scan.tsv 06 CLINVAR_CDNA_CHECK nextflow tsv stable - - -file {sample}/clinvar/{sample}_clinvar_deep_intronic.tsv 06 CLINVAR_SCREEN nextflow tsv stable - - +file {sample}/clinical/{sample}_clinical_summary.tsv 23 CLINICAL_FILTER both tsv stable - - +file {sample}/clinvar/{sample}_clinvar_cdna_check.tsv 06 CLINVAR_CDNA_CHECK both tsv stable - - +file {sample}/clinvar/{sample}_clinvar_cdna_scan.tsv 06 CLINVAR_CDNA_CHECK both tsv stable - - +file {sample}/clinvar/{sample}_clinvar_deep_intronic.tsv 06 CLINVAR_SCREEN both tsv stable - - file {sample}/clinvar/{sample}_clinvar_hits.tsv 06 CLINVAR_SCREEN both tsv stable both chrom,pos,ref,alt,genotype,clinvar_id,geneinfo,clnsig,clnrevstat file {sample}/clinvar/{sample}_clinvar_hits.vcf 06 CLINVAR_SCREEN both vcf stable both INFO/GENEINFO,INFO/CLNSIG,INFO/CLNREVSTAT file {sample}/clinvar/{sample}_pass.vcf.gz 06 CLINVAR_SCREEN both vcf stable both FORMAT/GT file {sample}/clinvar/{sample}_pass.vcf.gz.tbi 06 CLINVAR_SCREEN both index stable both - file {sample}/cnvpytor/{sample}_cnvs.raw.vcf 18 CNVPYTOR nextflow vcf internal - - -file {sample}/cnvpytor/{sample}_cnvs.txt 18 CNVPYTOR nextflow text stable - - -file {sample}/cnvpytor/{sample}_cnvs.vcf.gz 18 CNVPYTOR_VCF nextflow vcf stable - - +file {sample}/cnvpytor/{sample}_cnvs.txt 18 CNVPYTOR both text stable - - +file {sample}/cnvpytor/{sample}_cnvs.vcf.gz 18 CNVPYTOR_VCF both vcf stable - - file {sample}/cnvpytor/{sample}_cnvs.vcf.gz.tbi 18 CNVPYTOR_VCF nextflow index stable - - -file {sample}/consumer_export/{sample}_23andme_v5.zip 40 CONSUMER_EXPORT nextflow zip experimental - - -file {sample}/consumer_export/{sample}_ancestrydna_layout.zip 40 CONSUMER_EXPORT nextflow zip experimental - - -file {sample}/consumer_export/{sample}_consumer_export.tsv 40 CONSUMER_EXPORT nextflow tsv experimental - - -file {sample}/couple/{sample}_couple_carrier.tsv 39 COUPLE_CARRIER nextflow tsv experimental - - +file {sample}/consumer_export/{sample}_23andme_v5.zip 40 CONSUMER_EXPORT both zip experimental - - +file {sample}/consumer_export/{sample}_ancestrydna_layout.zip 40 CONSUMER_EXPORT both zip experimental - - +file {sample}/consumer_export/{sample}_consumer_export.tsv 40 CONSUMER_EXPORT both tsv experimental - - +file {sample}/couple/{sample}_couple_carrier.tsv 39 COUPLE_CARRIER both tsv experimental - - file {sample}/coverage/{sample}.mosdepth.global.dist.txt 16b MOSDEPTH nextflow text stable - - file {sample}/coverage/{sample}.mosdepth.summary.txt 16b MOSDEPTH nextflow tsv stable nextflow chrom,length,bases,mean,min,max file {sample}/coverage/{sample}.regions.bed.gz 16b MOSDEPTH nextflow bed stable - - @@ -105,122 +104,123 @@ file {sample}/coverage/{sample}.thresholds.bed.gz 16b MOSDEPTH nextflow bed stab file {sample}/coverage/{sample}_callability.bed.gz 16b MOSDEPTH nextflow bed stable - - file {sample}/cpic/{sample}_cpic_recommendations.txt 27 CPIC_LOOKUP both text stable both - file {sample}/cpic/{sample}_phenotypes.tsv 27 CPIC_LOOKUP both tsv stable both - -file {sample}/cpsr/{sample}.cpsr.grch38.classification.tsv.gz 17 CPSR nextflow tsv stable - - -file {sample}/cpsr/{sample}.cpsr.grch38.html 17 CPSR nextflow html stable - - +file {sample}/cpsr/{sample}.cpsr.grch38.classification.tsv.gz 17 CPSR both tsv stable - - +file {sample}/cpsr/{sample}.cpsr.grch38.html 17 CPSR both html stable - - file {sample}/cpsr/{sample}_cpsr_cdna_check.tsv 17 CLINVAR_CDNA_CHECK both tsv stable - - -file {sample}/cyrius/{sample}_cyp2d6.tsv 21 CYRIUS nextflow tsv stable - - -file {sample}/cyrius/{sample}_cyp2d6_depth_check.tsv 21 CYRIUS nextflow tsv stable - - -file {sample}/delly/{sample}_sv.vcf.gz 19 DELLY_BCF2VCF nextflow vcf stable nextflow INFO/SVTYPE +file {sample}/cyrius/{sample}_cyp2d6.tsv 21 CYRIUS both tsv stable - - +file {sample}/cyrius/{sample}_cyp2d6_depth_check.tsv 21 CYRIUS both tsv stable - - +file {sample}/delly/{sample}_sv.vcf.gz 19 DELLY_BCF2VCF both vcf stable nextflow INFO/SVTYPE file {sample}/delly/{sample}_sv.vcf.gz.tbi 19 DELLY_BCF2VCF nextflow index stable nextflow - -file {sample}/exomiser/{sample}_exomiser.txt 48 EXOMISER_REPORT nextflow text experimental - - -file {sample}/exomiser/{sample}_exomiser.variants.tsv 48 EXOMISER nextflow tsv experimental - - -file {sample}/exomiser/{sample}_exomiser_terms.tsv 48 EXOMISER_REPORT nextflow tsv experimental - - -file {sample}/exomiser/{sample}_exomiser_top.tsv 48 EXOMISER_REPORT nextflow tsv experimental - - +file {sample}/exomiser/{sample}_exomiser.txt 48 EXOMISER_REPORT both text experimental - - +file {sample}/exomiser/{sample}_exomiser.variants.tsv 48 EXOMISER both tsv experimental - - +file {sample}/exomiser/{sample}_exomiser_terms.tsv 48 EXOMISER_REPORT both tsv experimental - - +file {sample}/exomiser/{sample}_exomiser_top.tsv 48 EXOMISER_REPORT both tsv experimental - - file {sample}/expansion_hunter/{sample}.json 09 EXPANSION_HUNTER nextflow json stable - - file {sample}/expansion_hunter/{sample}.vcf 09 EXPANSION_HUNTER nextflow vcf stable - - -file {sample}/expansion_hunter/{sample}_eh_stranger.vcf 09b STRANGER nextflow vcf stable - - +file {sample}/expansion_hunter/{sample}_eh_stranger.vcf 09b STRANGER both vcf stable - - file {sample}/expansion_hunter/{sample}_realigned.bam 09 EXPANSION_HUNTER nextflow bam stable - - -file {sample}/expansion_hunter/{sample}_rfc1_motif.tsv 09b STRANGER_RFC1 nextflow tsv stable - - -file {sample}/expansion_hunter/{sample}_str_motifs.tsv 09b STRANGER_RFC1 nextflow tsv stable - - +file {sample}/expansion_hunter/{sample}_rfc1_motif.tsv 09b STRANGER_RFC1 both tsv stable - - +file {sample}/expansion_hunter/{sample}_str_motifs.tsv 09b STRANGER_RFC1 both tsv stable - - file {sample}/fastq_trimmed/{sample}_R1.fastq.gz 01b FASTP nextflow bin internal - - file {sample}/fastq_trimmed/{sample}_R2.fastq.gz 01b FASTP nextflow bin internal - - -file {sample}/fastq_trimmed/{sample}_fastp.html 01b FASTP nextflow html stable - - -file {sample}/fastq_trimmed/{sample}_fastp.json 01b FASTP nextflow json stable - - +file {sample}/fastq_trimmed/{sample}_fastp.html 01b FASTP both html stable - - +file {sample}/fastq_trimmed/{sample}_fastp.json 01b FASTP both json stable - - file {sample}/hla/{sample}_hla_genotype.tsv 08 HLA_TYPING nextflow tsv stable - - -file {sample}/hla_optitype/{sample}_hla_concordance.tsv 08 HLA_QC nextflow tsv experimental - - -file {sample}/hla_optitype/{sample}_optitype_result.tsv 08 HLA_QC nextflow tsv experimental - - +file {sample}/hla_optitype/{sample}_hla_concordance.tsv 08 HLA_QC both tsv experimental - - +file {sample}/hla_optitype/{sample}_optitype_result.tsv 08 HLA_QC both tsv experimental - - file {sample}/hla_t1k/{sample}_hla_genotype.tsv 08 - bash tsv stable bash - -file {sample}/indexcov 16 INDEXCOV nextflow dir stable - - +file {sample}/indexcov 16 INDEXCOV both dir stable - - file {sample}/indexcov/indexcov-indexcov.ped 16 - bash tsv stable bash - file {sample}/kir/database_release.txt 08 KIR_TYPING nextflow text experimental - - -file {sample}/kir/{sample}_kir_genotype.tsv 08 KIR_TYPING nextflow tsv experimental - - +file {sample}/kir/{sample}_kir_genotype.tsv 08 KIR_TYPING both tsv experimental - - file {sample}/logs/run_status.tsv - - nextflow tsv stable - - -file {sample}/lpa/{sample}_lpa_kiv2.tsv 46 LPA nextflow tsv experimental - - -file {sample}/lpa/{sample}_lpa_kiv2_depth.tsv 46 LPA_DEPTH nextflow tsv experimental - - -file {sample}/lpa/{sample}_lpa_kiv2_depth_q1.tsv 46 LPA_DEPTH nextflow tsv experimental - - -file {sample}/manta/results/variants/candidateSV.vcf.gz 04 MANTA nextflow vcf stable - - -file {sample}/manta/results/variants/diploidSV.raw.vcf.gz 04 MANTA nextflow vcf stable - - -file {sample}/manta/results/variants/diploidSV.raw.vcf.gz.tbi 04 MANTA nextflow index stable - - -file {sample}/manta/results/variants/diploidSV.vcf.gz 04 MANTA nextflow vcf stable - - -file {sample}/manta/results/variants/diploidSV.vcf.gz.tbi 04 MANTA nextflow index stable - - -file {sample}/mei/{sample}_megane.log 50 MEI nextflow text experimental - - -file {sample}/mei/{sample}_megane.vcf 50 MEI nextflow vcf experimental - - -file {sample}/mei/{sample}_mei.tsv 50 MEI_REPORT nextflow tsv experimental - - -file {sample}/mei/{sample}_mei_annotsv.tsv 50 MEI_REPORT nextflow tsv experimental - - -file {sample}/mei/{sample}_mei_depth.tsv 50 MEI_DEPTH nextflow tsv experimental - - +file {sample}/lpa/{sample}_lpa_kiv2.tsv 46 LPA both tsv experimental - - +file {sample}/lpa/{sample}_lpa_kiv2_depth.tsv 46 LPA_DEPTH both tsv experimental - - +file {sample}/lpa/{sample}_lpa_kiv2_depth_q1.tsv 46 LPA_DEPTH both tsv experimental - - +file {sample}/manta/results/variants/candidateSV.vcf.gz 04 MANTA both vcf stable - - +file {sample}/manta/results/variants/diploidSV.raw.vcf.gz 04 MANTA both vcf stable - - +file {sample}/manta/results/variants/diploidSV.raw.vcf.gz.tbi 04 MANTA both index stable - - +file {sample}/manta/results/variants/diploidSV.vcf.gz 04 MANTA both vcf stable - - +file {sample}/manta/results/variants/diploidSV.vcf.gz.tbi 04 MANTA both index stable - - +file {sample}/mei/{sample}_megane.log 50 MEI both text experimental - - +file {sample}/mei/{sample}_megane.vcf 50 MEI both vcf experimental - - +file {sample}/mei/{sample}_mei.tsv 50 MEI_REPORT both tsv experimental - - +file {sample}/mei/{sample}_mei_annotsv.tsv 50 MEI_REPORT both tsv experimental - - +file {sample}/mei/{sample}_mei_depth.tsv 50 MEI_DEPTH both tsv experimental - - file {sample}/mei/{sample}_mei_mainchr.txt 50 MEI_DEPTH nextflow text internal - - -file {sample}/mei/{sample}_mei_sites.tsv 50 MEI_REPORT nextflow tsv experimental - - -file {sample}/mito/{sample}_chrM.fasta 12 MITO_EXTRACT_CHRM nextflow fasta stable - - +file {sample}/mei/{sample}_mei_sites.tsv 50 MEI_REPORT both tsv experimental - - +file {sample}/mito/{sample}_chrM.fasta 12 MITO_EXTRACT_CHRM both fasta stable - - file {sample}/mito/{sample}_chrM.vcf.gz 12 MITO_EXTRACT_CHRM nextflow vcf internal - - file {sample}/mito/{sample}_chrM.vcf.gz.tbi 12 MITO_EXTRACT_CHRM nextflow index internal - - -file {sample}/mito/{sample}_chrM_annotated.tsv 20 MITO_VARIANTS_ANNOTATE nextflow tsv stable - - -file {sample}/mito/{sample}_chrM_deletions.tsv 20 MITO_DELETIONS nextflow tsv stable - - +file {sample}/mito/{sample}_chrM_annotated.tsv 20 MITO_VARIANTS_ANNOTATE both tsv stable - - +file {sample}/mito/{sample}_chrM_deletions.tsv 20 MITO_DELETIONS both tsv stable - - file {sample}/mito/{sample}_chrM_filtered.vcf.gz 20 MITO_VARIANTS both vcf stable bash - file {sample}/mito/{sample}_chrM_filtered.vcf.gz.tbi 20 MITO_VARIANTS nextflow index stable - - file {sample}/mito/{sample}_chrM_mutect2.vcf.gz.stats 20 MITO_VARIANTS nextflow bin stable - - -file {sample}/mito/{sample}_haplocheck.txt 12 HAPLOCHECK nextflow text stable - - +file {sample}/mito/{sample}_haplocheck.txt 12 HAPLOCHECK both text stable - - file {sample}/mito/{sample}_haplogroup.txt 12 MITO_HAPLOGROUP both text stable bash - -file {sample}/mito/{sample}_mtdna_copies.tsv 20 MITO_DELETIONS nextflow tsv stable - - -file {sample}/mosaic/{sample}_mocha.log 51 MOCHA_CALL nextflow text experimental - - -file {sample}/mosaic/{sample}_mocha.tsv 51 MOCHA_REPORT nextflow tsv experimental - - -file {sample}/mosaic/{sample}_mocha_calls_raw.tsv 51 MOCHA_CALL nextflow tsv experimental - - -file {sample}/mosaic/{sample}_mocha_sites.tsv 51 MOCHA_CALL nextflow tsv experimental - - -file {sample}/mosaic/{sample}_mocha_stats.tsv 51 MOCHA_CALL nextflow tsv experimental - - -file {sample}/mosaic/{sample}_mocha_summary.tsv 51 MOCHA_REPORT nextflow tsv experimental - - +file {sample}/mito/{sample}_mtdna_copies.tsv 20 MITO_DELETIONS both tsv stable - - +file {sample}/mosaic/{sample}_mocha.log 51 MOCHA_CALL both text experimental - - +file {sample}/mosaic/{sample}_mocha.tsv 51 MOCHA_REPORT both tsv experimental - - +file {sample}/mosaic/{sample}_mocha_calls_raw.tsv 51 MOCHA_CALL both tsv experimental - - +file {sample}/mosaic/{sample}_mocha_sites.tsv 51 MOCHA_CALL both tsv experimental - - +file {sample}/mosaic/{sample}_mocha_stats.tsv 51 MOCHA_CALL both tsv experimental - - +file {sample}/mosaic/{sample}_mocha_summary.tsv 51 MOCHA_REPORT both tsv experimental - - file {sample}/locityper/{sample}_locityper.log 53 LOCITYPER both text experimental - - file {sample}/locityper/{sample}_locityper.tsv 53 LOCITYPER both tsv experimental - - file {sample}/locityper/work 53 LOCITYPER both dir experimental - - file {sample}/mosdepth/{sample}.mosdepth.summary.txt 16b - bash tsv stable bash chrom,length,bases,mean,min,max -file {sample}/paralogs/{sample}_*_copy_number.tsv 35 PARASCOPY nextflow tsv experimental - - -file {sample}/paralogs/{sample}_cn_dosage.tsv 35 PARASCOPY nextflow tsv experimental - - -file {sample}/paralogs/{sample}_cn_intervals.tsv 35 PARASCOPY nextflow tsv experimental - - +file {sample}/paralogs/{sample}_*_copy_number.tsv 35 PARASCOPY both tsv experimental - - +file {sample}/paralogs/{sample}_cn_dosage.tsv 35 PARASCOPY both tsv experimental - - +file {sample}/paralogs/{sample}_cn_intervals.tsv 35 PARASCOPY both tsv experimental - - file {sample}/paralogs/{sample}_cyp21a2_psv.tsv 35 PARASCOPY both tsv experimental - - -file {sample}/paralogs/{sample}_hba_copy_number.tsv 35 PARASCOPY nextflow tsv experimental - - -file {sample}/paralogs/{sample}_paraphase 35b PARAPHASE nextflow dir experimental - - -file {sample}/paralogs/{sample}_paraphase_copy_number.tsv 35b PARAPHASE_REPORT nextflow tsv experimental - - -file {sample}/paralogs/{sample}_paraphase_variants.tsv 35b PARAPHASE_REPORT nextflow tsv experimental - - -file {sample}/paralogs/{sample}_parascopy 35 PARASCOPY nextflow dir experimental - - -file {sample}/paralogs/{sample}_smn_copy_number.tsv 35 PARASCOPY nextflow tsv experimental - - -file {sample}/pgx_consensus/{sample}_outside_calls.tsv 36 PGX_CONSENSUS nextflow tsv stable - - -file {sample}/pgx_consensus/{sample}_pgx_consensus.tsv 36 PGX_CONSENSUS nextflow tsv stable - - +file {sample}/paralogs/{sample}_hba_copy_number.tsv 35 PARASCOPY both tsv experimental - - +file {sample}/paralogs/{sample}_paraphase 35b PARAPHASE both dir experimental - - +file {sample}/paralogs/{sample}_paraphase_copy_number.tsv 35b PARAPHASE_REPORT both tsv experimental - - +file {sample}/paralogs/{sample}_paraphase_variants.tsv 35b PARAPHASE_REPORT both tsv experimental - - +file {sample}/paralogs/{sample}_parascopy 35 PARASCOPY both dir experimental - - +file {sample}/paralogs/{sample}_smn_copy_number.tsv 35 PARASCOPY both tsv experimental - - +file {sample}/pgx_consensus/{sample}_outside_calls.tsv 36 PGX_CONSENSUS both tsv stable - - +file {sample}/pgx_consensus/{sample}_pgx_consensus.tsv 36 PGX_CONSENSUS both tsv stable - - file {sample}/pharmcat/{sample}.match.json 07 PHARMCAT nextflow json stable - - file {sample}/pharmcat/{sample}.phenotype.json 07 PHARMCAT nextflow json stable - - file {sample}/pharmcat/{sample}.report.html 07 PHARMCAT nextflow html stable - - file {sample}/pharmcat/{sample}.report.json 07 PHARMCAT nextflow json stable nextflow - -file {sample}/prs/pgsc_calc 25 PRS nextflow dir stable - - +file {sample}/prs/pgsc_calc 25 PRS both dir stable - - file {sample}/prs/{sample}_prs_summary.*.tsv 25 PRS_SUMMARY nextflow tsv stable - - -file {sample}/prs/{sample}_prs_summary.tsv 25 PRS_SUMMARY nextflow tsv stable - - -file {sample}/pypgx/{sample}_cyp2d6_depth_check.tsv 32 PYPGX nextflow tsv stable - - -file {sample}/pypgx/{sample}_pharmcat_comparison.tsv 27 CPIC_LOOKUP nextflow tsv stable - - +file {sample}/prs/{sample}_prs_summary.tsv 25 PRS_SUMMARY both tsv stable - - +file {sample}/pypgx/{sample}_cyp2d6_depth_check.tsv 32 PYPGX both tsv stable - - +file {sample}/pypgx/{sample}_pharmcat_comparison.tsv 27 CPIC_LOOKUP both tsv stable - - file {sample}/pypgx/{sample}_pypgx_results 32 PYPGX nextflow dir stable - - file {sample}/pypgx/{sample}_pypgx_summary.tsv 32 PYPGX both tsv stable bash - file {sample}/qc/somalier/{sample}.somalier 33 SOMALIER nextflow bin experimental - - file {sample}/qc/somalier/{sample}.somalier_id 33 SOMALIER nextflow bin internal - - file {sample}/qc/verifybamid2/{sample}.Ancestry 33 VERIFYBAMID2 nextflow bin experimental - - -file {sample}/qc/verifybamid2/{sample}.log 33 VERIFYBAMID2 nextflow text experimental - - +file {sample}/qc/verifybamid2/{sample}.log 33 VERIFYBAMID2 both text experimental - - file {sample}/qc/verifybamid2/{sample}.marker_check 33 VERIFYBAMID2 nextflow bin internal - - -file {sample}/qc/verifybamid2/{sample}.selfSM 33 VERIFYBAMID2 nextflow bin experimental - - -file {sample}/qc/{sample}_sample_qc.tsv 33 SAMPLE_QC nextflow tsv experimental - - +file {sample}/qc/verifybamid2/{sample}.selfSM 33 VERIFYBAMID2 both bin experimental - - +file {sample}/qc/{sample}_read_count.tsv 02 ALIGN_MINIMAP2 both tsv stable - - +file {sample}/qc/{sample}_sample_qc.tsv 33 SAMPLE_QC both tsv experimental - - file {sample}/roh/{sample}_roh.txt 11 ROH nextflow text stable nextflow - file {sample}/roh/{sample}_roh_summary.txt 11 ROH nextflow tsv stable nextflow chrom,start,end,length_bp,length_mb file {sample}/run_manifest.tsv - - nextflow tsv stable - - file {sample}/slivar/{sample}.ped 31 SLIVAR_PRIORITIZE nextflow tsv internal - - -file {sample}/slivar/{sample}_compound_het_pairs.tsv 31 COMPHET_PHASE nextflow tsv stable - - +file {sample}/slivar/{sample}_compound_het_pairs.tsv 31 COMPHET_PHASE both tsv stable - - file {sample}/slivar/{sample}_compound_hets.vcf.gz 31 SLIVAR both vcf stable bash - file {sample}/slivar/{sample}_prioritized.vcf.gz 31 SLIVAR_PRIORITIZE both vcf stable bash - file {sample}/slivar/{sample}_prioritized.vcf.gz.tbi 31 SLIVAR_PRIORITIZE nextflow index stable - - -file {sample}/slivar/{sample}_slivar_summary.tsv 31 SLIVAR_PRIORITIZE nextflow tsv stable - - +file {sample}/slivar/{sample}_slivar_summary.tsv 31 SLIVAR_PRIORITIZE both tsv stable - - file {sample}/summary.json 24 HTML_REPORT both json stable bash schema_version,sample,generated_utc,run,databases,manifest,sections,cyp2d6,not_run,not_assessed file {sample}/sv_duphold/{sample}_sv_duphold.vcf 15 DUPHOLD nextflow vcf stable - - file {sample}/sv_filtered/{sample}_sv_filtered.log 15 DUPHOLD_FILTER nextflow text stable - - file {sample}/sv_filtered/{sample}_sv_filtered.vcf.gz 15 DUPHOLD_FILTER nextflow vcf stable - - file {sample}/sv_filtered/{sample}_sv_filtered.vcf.gz.tbi 15 DUPHOLD_FILTER nextflow index stable - - -file {sample}/sv_merged/{sample}_sv_consensus.vcf.gz 22 SURVIVOR_SORT nextflow vcf stable - - -file {sample}/sv_merged/{sample}_sv_consensus.vcf.gz.tbi 22 SURVIVOR_SORT nextflow index stable - - -file {sample}/telomere/{sample} 10 TELOMERE_HUNTER nextflow dir stable - - -file {sample}/traits/{sample}_genotype_rules.tsv 42 TRAITS nextflow tsv stable - - -file {sample}/traits/{sample}_traits.tsv 42 TRAITS nextflow tsv stable - - -file {sample}/traits/{sample}_traits_genotypes.tsv 42 TRAITS_SITES nextflow tsv stable - - +file {sample}/sv_merged/{sample}_sv_consensus.vcf.gz 22 SURVIVOR_SORT both vcf stable - - +file {sample}/sv_merged/{sample}_sv_consensus.vcf.gz.tbi 22 SURVIVOR_SORT both index stable - - +file {sample}/telomere/{sample} 10 TELOMERE_HUNTER both dir stable - - +file {sample}/traits/{sample}_genotype_rules.tsv 42 TRAITS both tsv stable - - +file {sample}/traits/{sample}_traits.tsv 42 TRAITS both tsv stable - - +file {sample}/traits/{sample}_traits_genotypes.tsv 42 TRAITS_SITES both tsv stable - - file {sample}/vcf/{sample}.g.vcf.gz 03 DEEPVARIANT both vcf stable - - file {sample}/vcf/{sample}.g.vcf.gz.tbi 03 DEEPVARIANT both index stable - - file {sample}/vcf/{sample}.report.html 07 - bash html stable bash - @@ -231,36 +231,36 @@ file {sample}/vcf/{sample}_roh.txt 11 - bash text stable bash - file {sample}/vcf/{sample}_roh_summary.txt 11 - bash tsv stable bash chrom,start,end,length_bp,length_mb file {sample}/vcf_gatk/{sample}.vcf.gz 03a - bash vcf stable bash FORMAT/GT file {sample}/vep/{sample}_annotated.vcf.gz 30 VCFANNO_INDEX both vcf stable both INFO/REVEL -file {sample}/vep/{sample}_annotated.vcf.gz.tbi 30 VCFANNO_INDEX nextflow index stable nextflow - +file {sample}/vep/{sample}_annotated.vcf.gz.tbi 30 VCFANNO_INDEX both index stable nextflow - file {sample}/vep/{sample}_vep.vcf.gz 13 VEP both vcf stable - - -file {sample}/vep/{sample}_vep.vcf.gz.tbi 13 VEP nextflow index stable - - +file {sample}/vep/{sample}_vep.vcf.gz.tbi 13 VEP both index stable - - file {sample}/vep/{sample}_vep.vcf_summary.html 13 VEP nextflow html stable - - -file {sample}/viral/{sample}_human_coverage.tsv 47 VIRAL_READS nextflow tsv experimental - - -file {sample}/viral/{sample}_viral.tsv 47 VIRAL_REPORT nextflow tsv experimental - - -file {sample}/viral/{sample}_viral_coverage.tsv 47 VIRAL_READS nextflow tsv experimental - - -file {sample}/viral/{sample}_viral_depth.tsv 47 VIRAL_READS nextflow tsv experimental - - -file {sample}/viral/{sample}_viral_reads.bam 47 VIRAL_READS nextflow bam experimental - - -file {sample}/viral/{sample}_viral_reads.bam.bai 47 VIRAL_READS nextflow index experimental - - -file {sample}/vntyper/run 52 VNTYPER nextflow dir experimental - - -file {sample}/vntyper/{sample}_vntyper.log 52 VNTYPER nextflow text experimental - - -file {sample}/vntyper/{sample}_vntyper.tsv 52 VNTYPER nextflow tsv experimental - - -file {sample}/y_haplogroup/{sample}_chrY.bam 37 Y_PILEUP nextflow bam experimental - - -file {sample}/y_haplogroup/{sample}_chrY.bam.bai 37 Y_PILEUP nextflow index experimental - - +file {sample}/viral/{sample}_human_coverage.tsv 47 VIRAL_READS both tsv experimental - - +file {sample}/viral/{sample}_viral.tsv 47 VIRAL_REPORT both tsv experimental - - +file {sample}/viral/{sample}_viral_coverage.tsv 47 VIRAL_READS both tsv experimental - - +file {sample}/viral/{sample}_viral_depth.tsv 47 VIRAL_READS nextflow tsv internal - - +file {sample}/viral/{sample}_viral_reads.bam 47 VIRAL_READS both bam experimental - - +file {sample}/viral/{sample}_viral_reads.bam.bai 47 VIRAL_READS both index experimental - - +file {sample}/vntyper/run 52 VNTYPER both dir experimental - - +file {sample}/vntyper/{sample}_vntyper.log 52 VNTYPER both text experimental - - +file {sample}/vntyper/{sample}_vntyper.tsv 52 VNTYPER both tsv experimental - - +file {sample}/y_haplogroup/{sample}_chrY.bam 37 Y_PILEUP both bam experimental - - +file {sample}/y_haplogroup/{sample}_chrY.bam.bai 37 Y_PILEUP both index experimental - - file {sample}/y_haplogroup/{sample}_idxstats.txt 37 Y_PILEUP nextflow text internal - - -file {sample}/y_haplogroup/{sample}_y_haplogroup.txt 37 Y_HAPLOGROUP nextflow text experimental - - +file {sample}/y_haplogroup/{sample}_y_haplogroup.txt 37 Y_HAPLOGROUP both text experimental - - file {sample}/y_haplogroup/{sample}_y_pileup.txt 37 Y_PILEUP nextflow text internal - - -file {sample}/y_haplogroup/{sample}_y_private_snps.tsv 37 Y_PRIVATE nextflow tsv experimental - - -file {sample}/y_haplogroup/{sample}_y_tree.txt 37 Y_HAPLOGROUP nextflow text experimental - - -file {sample}/y_haplogroup/{sample}_y_tree_path.tsv 37 Y_HAPLOGROUP nextflow tsv experimental - - +file {sample}/y_haplogroup/{sample}_y_private_snps.tsv 37 Y_PRIVATE both tsv experimental - - +file {sample}/y_haplogroup/{sample}_y_tree.txt 37 Y_HAPLOGROUP both text experimental - - +file {sample}/y_haplogroup/{sample}_y_tree_path.tsv 37 Y_HAPLOGROUP both tsv experimental - - file {sample}/y_haplogroup/{sample}_y_tree_pileup.txt 37 Y_PILEUP nextflow text internal - - -file {sample}/y_str/{sample}_y_str.csv 45 Y_STR nextflow csv experimental - - -file {sample}/y_str/{sample}_y_str.log 45 Y_STR nextflow text experimental - - -file {sample}/y_str/{sample}_y_str.tsv 45 Y_STR nextflow tsv experimental - - -file {sample}/y_str/{sample}_y_str.vcf.gz 45 Y_STR nextflow vcf experimental - - -file {sample}/{sample}_charts.json 24 HTML_REPORT nextflow json stable - - +file {sample}/y_str/{sample}_y_str.csv 45 Y_STR both csv experimental - - +file {sample}/y_str/{sample}_y_str.log 45 Y_STR both text experimental - - +file {sample}/y_str/{sample}_y_str.tsv 45 Y_STR both tsv experimental - - +file {sample}/y_str/{sample}_y_str.vcf.gz 45 Y_STR both vcf experimental - - +file {sample}/{sample}_charts.json 24 HTML_REPORT both json stable - - file {sample}/{sample}_report.html 24 HTML_REPORT both html stable bash - file {sample}/{sample}_report.txt 24 - both text stable bash - -file {sample}/{sample}_search_index.json 24 HTML_REPORT nextflow json stable - - +file {sample}/{sample}_search_index.json 24 HTML_REPORT both json stable - - file {sample}/{sample}_sex_check.tsv 16 INDEXCOV nextflow tsv stable - - param input - - - - stable - - param outdir - - - - stable - - @@ -311,6 +311,7 @@ param parascopy_data - - - - experimental - - param yleaf_data - - - - experimental - - param hpo - - - - experimental - - param exomiser_data - - - - experimental - - +param pedigree - - - - experimental - - param family_name - - - - experimental - - param exomiser_proband - - - - experimental - - param exomiser_assembly - - - - experimental - - diff --git a/bin/live_results.py b/bin/live_results.py new file mode 100644 index 00000000..74f77a53 --- /dev/null +++ b/bin/live_results.py @@ -0,0 +1,689 @@ +#!/usr/bin/env python3 +"""live_results.py: one person's results as the live site draws them (results.json). + + build(sample, summary, files=()) -> dict + +Pure functions, standard library only. The live site's results plugin +(bin/owngenome_live_results.py) and the public demo (tests/demo/build_live_demo.py) +call build() on a summary.json (bin/collect_summary.py). The answer follows the +report's own chapters (bin/render_report.py CHAPTERS): seven questions, one card per +section id, the same anchors as report.html. Each card carries: + + facts [[label, value]] the numbers the report prints + table {"columns": [...], "rows": [[...]]} the rows the report lists, or null + lines the report's own sentences (render_report's *_lines helpers), plain text + caveat the section's caveats word for word (render_report.SECTION_NOTES) + attention true only for a flag the report or the live page already raises: a + headline tone (bin/owngenome_live.py HEADLINES), a failed, unreadable or + stale result, a repeat outside Stranger's "normal" or in an intermediate + zone, an AnnotSV class 4 or 5 row, a CHIP or MoChA call, a two-hit gene + or a couple's shared or X-linked gene. Nothing says a section is fine: + a card without attention is "for information". + +Nothing here reads a file or computes anything about genetics; the adapters pick +fields the summary already holds. A section id with no adapter falls back to its +report headline and a link to the report. +""" +import re + +import render_report as rr + +SCHEMA = 1 +# Words that would tell a reader a result is fine. tests/test_live_results.py scans every string of +# results.json and family.json, and the page assets, for them. "Fine-grained" is a method's name. +FORBIDDEN = re.compile(r"\b(ok|(? limit: + out["more"] = len(rows) - limit + return out + + +def _headline_tone(key, sec, summary): + """(text, tone) from the live site's headline table, or (None, 'info').""" + try: + import owngenome_live + h = owngenome_live.headline(key, sec, summary) + except Exception: # the page must not go down on one reader + h = None + return (plain(h[0]), h[1]) if h else (None, "info") + + +# --- the adapters: one per section id -------------------------------------------------------- +# Each takes (S, summary) where S is summary["sections"], and returns a dict with any of +# facts, table, lines, attention, links (relative file patterns), extra. + +def a_qc(S, s): + cov, sexc, q, rdc = (S.get(k) or {} for k in ("coverage", "sex_check", "sample_qc", "read_count")) + facts = [] + md = (cov.get("data") or {}).get("mean_depth") + if isinstance(md, (int, float)): + facts.append(["Mean depth", f"{md:.1f}x"]) + if rdc.get("state") in ("ok", "stale"): + d = rdc["data"] + facts.append(["Reads kept by alignment", rr.read_count_text(d) + + ("" if d.get("read_count_check") != "mismatch" else " (reads lost: see the report)")]) + inferred = (sexc.get("data") or {}).get("inferred_sex") + declared = (s.get("run") or {}).get("declared_sex") + if inferred: + facts.append(["Inferred sex (indexcov)", inferred]) + facts.append(["Declared sex", declared or "not declared"]) + attention = False + if q.get("state") in ("ok", "stale"): + d = q["data"] + check = d.get("sex_check") + word = {"ok": "Declared and measured sex agree", "mismatch": "Declared and measured sex differ", + "not_checked": f"Not checked: {d.get('sex_check_reason') or 'no reason given'}"}.get(check, check or ".") + facts.append(["Sex from the reads (somalier)", f"{d.get('inferred_sex') or '.'}. {word}"]) + attention = check == "mismatch" + cont = d.get("contamination") + if cont == "ok": + facts.append(["Contamination (VerifyBamID2)", f"Below the contamination flag (FREEMIX {d.get('freemix')}, " + f"flag above {d.get('freemix_warn_above')})"]) + else: + facts.append(["Contamination (VerifyBamID2)", rr.freemix_text(d)]) + attention = attention or cont == "warn" + if d.get("same_person_as"): + facts.append(["The same person as", d["same_person_as"]]) + attention = True + lines = [x for x in rr.karyotype_lines(S)] + if lines[:-1]: + attention = True + return {"facts": facts, "lines": lines, "attention": attention, + "links": [r"(^|/)multiqc[^/]*\.html$", r"(^|/)fastp[^/]*\.html$", r"_fastp\.html$"]} + + +def a_callability(S, s): + d = S["callability"]["data"] + rows = [[g["gene"], g.get("status"), "." if g.get("share") is None else f"{100 * g['share']:.0f}%", + ", ".join(g.get("reasons") or [])] for g in d.get("not_read") or []] + return {"facts": [["Hard-to-read genes read", f"{d.get('read')} of {d.get('catalog_genes')}"], + ["Not read, no other step calls them", len(d.get("not_read") or [])], + ["Not read here, called by a step of their own", len(d.get("not_read_owned") or [])]], + "table": table(["Gene", "Status", "Bases read", "Why"], rows)} + + +def a_variants(S, s): + d = S["variants"]["data"] + return {"facts": [["Total variants", d.get("total")], ["PASS variants", d.get("pass")], ["SNPs", d.get("snps")], + ["Indels", d.get("indels")]], "links": [r"(^|/)vep/.*summary\.html$"]} + + +def clinvar_call(h): + """A hit's Call cell: the low-confidence mark, the cDNA read check and the combined-allele note, + each as the report prints it.""" + parts = [rr.call_text(h) or ("low confidence" if h.get("low_confidence") else "")] + if "cdna" in h: + parts.append("reads: " + rr.cdna_text(h)) + parts.append(rr.combined_cell(h)) + return "; ".join(x for x in parts if x) or "." + + +def a_clinvar(S, s): + d = S["clinvar"]["data"] + by = d.get("by_stars") or {} + facts = [["P/LP matches", d.get("count", 0)], + ["By review stars (4/3/2/1/0)", " / ".join(str(by.get(k, 0)) for k in ("4", "3", "2", "1", "0"))], + ["ClinVar file date", (s.get("databases") or {}).get("clinvar_release") or "unknown"]] + if "low_confidence" in d: + f = d.get("call_floor") or {} + facts.append([f"Low-confidence calls (GQ below {f.get('GQ')} or DP below {f.get('DP')}), not counted", + d["low_confidence"]]) + rows = [] + for h in (d.get("hits") or [])[:50]: + rows.append([h.get("gene"), f"{h.get('chrom')}:{h.get('pos')} {h.get('ref')}>{h.get('alt')}", h.get("genotype"), + h.get("significance"), "★" * int(h.get("stars") or 0) or "0", + rr.af_text(h) or "frequency unknown", clinvar_call(h)]) + 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')} " + 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: + lines.insert(0, freq) + viewer = [f"{h.get('chrom')}:{h.get('pos')}" for h in (d.get("hits") or [])[:50]] + return {"facts": facts, "lines": lines, + "table": table(["Gene", "Variant", "Genotype", "Significance", "Stars", "Frequency", "Call"], rows), + "viewer": viewer} + + +def a_clinical_filter(S, s): + d = S["clinical"]["data"] + sf = d.get("acmg_sf") or {} + bi = d.get("by_impact") or {} + facts = [["Variants after the clinical filter", d.get("variants")], + ["By impact (high / moderate / low)", f"{bi.get('HIGH', 0)} / {bi.get('MODERATE', 0)} / {bi.get('LOW', 0)}"], + ["Genes", d.get("genes")]] + if sf.get("unavailable"): + # Not assessed is never zero findings: the report's sentence instead of the counts. + return {"facts": facts, "lines": rr.sf_apart_lines(sf), "attention": True} + rows = [[r[0], r[1], r[2], r[3]] for r in rr.sf_rows(sf)] if sf else [] + high = f"{rr.high_rare(sf)}{rr.high_apart(sf)}" if sf.get("high_impact_checked") else "not checked" + facts += [[f"{sf.get('version') or 'ACMG SF'}: ClinVar P/LP in its genes", len(sf.get("clinvar_hits") or [])], + ["High-impact rare variants in its genes", high]] + return {"facts": facts, + "table": table(["Gene", "Variant", "Kind", "Rule"], rows), + "attention": bool(sf.get("clinvar_hits") or sf.get("high_impact") or sf.get("structural"))} + + +def a_cpsr(S, s): + d = S["cpsr"]["data"] + facts = [["CPSR report", "Ran" if d.get("html_report") else "No HTML"]] + facts += [[k.replace("_", " "), v] for k, v in (d.get("classification") or {}).items()] + rows = [[x.get("gene"), x.get("variant"), x.get("final"), x.get("clinvar") or ".", + "low confidence" if x.get("low_confidence") else "."] for x in d.get("plp") or []] + return {"facts": facts, "lines": rr.cpsr_lines(d, s.get("databases") or {}), + "table": table(["Gene", "Variant", "Final class", "ClinVar", "Call"], rows), "links": [r"^cpsr/"]} + + +def a_slivar(S, s): + d = S["slivar"]["data"] + return {"facts": [["Prioritized variants", d.get("prioritized")], + ["Compound-het candidate variants", d.get("compound_het_variants")], + ["Compound-het candidate genes", d.get("compound_het_genes")]], + "lines": rr.comphet_phase_text(d) if "compound_het_phase" in d else []} + + +def a_exomiser(S, s): + d = S["exomiser"]["data"] + rows = [] + for g in d.get("genes") or []: + for v in g.get("variants") or [{}]: + rows.append([g.get("rank"), g.get("gene"), g.get("moi"), g.get("exomiser_score"), + v.get("hgvs") or v.get("variant"), v.get("genotype"), v.get("acmg"), v.get("disease")]) + return {"facts": [["Symptoms searched", ", ".join(f"{t.get('label')} ({t.get('id')})" for t in d.get("terms") or []) + or "none"]], + "table": table(["Rank", "Gene", "Inheritance", "Score", "Variant", "Genotype", "ACMG", "Disease"], rows)} + + +def a_structural(S, s): + m, dl, cn, sv = ((S.get(k) or {}).get("data") or {} for k in ("manta", "delly", "cnvpytor", "sv_consensus")) + av = (S.get("annotsv") or {}).get("data") or {} + facts = [["Manta SVs (total / PASS)", f"{m.get('total', '.')} / {m.get('pass', '.')}"], + ["Delly SVs (total / PASS)", f"{dl.get('total', '.')} / {dl.get('pass', '.')}"], + ["CNVpytor CNVs (significant)", f"{cn.get('total', '.')} ({cn.get('significant', '.')})"], + ["Consensus SVs (2+ callers)", sv.get("consensus", ".")], + ["AnnotSV class 4 and 5", av.get("count", ".")]] + rows = [[x.get("type"), x.get("sv"), x.get("gt"), x.get("length"), x.get("acmg_class"), + rr.annotsv_genes_text(x) if x.get("genes") is not None else ".", rr.annotsv_support_text(x), + "; ".join(rr.annotsv_flags(x)) if "exon_edge" in x else "."] + for x in av.get("rows") or []] + return {"facts": facts, "table": table(["Type", "Where", "Genotype", "Length", "Class", "Genes", "Support", + "Checks"], rows), + "attention": bool(av.get("count"))} + + +def a_mei(S, s): + d = S["mei"]["data"] + rows = [[x.get("gene"), x.get("element"), x.get("condition"), x.get("inheritance"), x.get("status"), + x.get("mean_depth")] for x in d.get("sites") or []] + return {"facts": [["Insertions called", d.get("calls")], + ["By element", ", ".join(f"{k} {v}" for k, v in (d.get("by_element") or {}).items()) or "none"]], + "table": table(["Gene", "Element", "Condition", "Inheritance", "Known site", "Depth"], rows), + "attention": any(x.get("status") not in (None, "", "not seen") for x in d.get("sites") or [])} + + +def a_expansions(S, s): + d = S["expansions"]["data"] + rows, flagged, outside = [], False, 0 + for l in d.get("loci") or []: + status = l.get("status") or "" + out = any(x and x != "normal" for x in status.split(",")) + outside += out + flagged = flagged or out or bool(l.get("zone")) + shown = rr.str_status(l) + count = rr.str_count(l) if isinstance(l.get("repeat_count"), str) else l.get("repeat_count") + rows.append([l.get("locus"), count, + f'"{shown}"' if shown not in ("", "not graded", "not sized") else (shown or "not graded"), + l.get("normal_max"), l.get("pathologic_min"), l.get("ci"), l.get("sizing"), + f'{l.get("zone_label")} ({l.get("zone")})' if l.get("zone") else "."]) + facts = [["Loci tested", d.get("records")], + ["Outside Stranger's \"normal\"", outside if d.get("stranger") else "Stranger not run"], + ["In an intermediate zone", ", ".join(d.get("intermediate") or []) or "none"], + ["Not sized from short reads", ", ".join(d.get("unsized") or []) or "none"]] + return {"facts": facts, "attention": flagged, + "table": table(["Locus", "Repeats", "Stranger status", "Normal up to", "Pathologic from", "95% interval", + "Sizing", "Intermediate zone"], rows), + "widget": {"kind": "str", "loci": [{k: l.get(k) for k in ("locus", "repeat_count", "status", "normal_max", + "pathologic_min", "ci")} + for l in d.get("loci") or []]}} + + +def a_vntyper(S, s): + d = S["vntyper"]["data"] + return {"facts": [["Result", d.get("status")], ["Confidence", d.get("confidence")], + ["Mean depth over the repeat", d.get("vntr_mean_depth")]], + "lines": rr.vntyper_lines(d), "attention": d.get("status") in ("positive", "possible")} + + +def a_mito(S, s): + d = S["mito"]["data"] + a = d.get("annotation") or {} + facts = [["PASS variants on chrM", d.get("pass")], ["Heteroplasmic sites", d.get("heteroplasmic")]] + if "clinvar_plp" in a: + facts.append(["ClinVar pathogenic or likely pathogenic", a.get("clinvar_plp")]) + rows = [[f"m.{x.get('pos')}{x.get('ref')}>{x.get('alt')}", f"{100 * x['af']:.1f}%" if isinstance(x.get("af"), (int, float)) + else "."] for x in d.get("heteroplasmic_sites") or []] + return {"facts": facts, "lines": rr.mito_lines(d), "table": table(["Site", "Allele fraction"], rows), + "attention": (a.get("clinvar_plp") or 0) > 0 or bool(d.get("confirmed"))} + + +def a_lpa(S, s): + d = S["lpa"]["data"] + facts = [["KIV-2 repeats", f"{d.get('kiv2_copies')} (95% interval {d.get('ci95_low')} to {d.get('ci95_high')})" + if d.get("status") == "estimated" else d.get("status")], ["Class", d.get("class")]] + return {"facts": facts, "lines": rr.lpa_lines(d), "attention": d.get("flag") == "high"} + + +def a_chip(S, s): + d = S["chip"]["data"] + rows = [[c.get("gene"), c.get("protein_change"), c.get("vaf"), c.get("alt_reads"), c.get("depth"), c.get("status")] + for c in d.get("calls") or []] + return {"facts": [["CHIP calls", len(d.get("calls") or [])], + ["Typical detection limit at this depth (VAF)", d.get("median_limit")]], + "table": table(["Gene", "Change", "VAF", "Alt reads", "Depth", "Status"], rows), + "lines": rr.chip_lines(d, s.get("sample")), "attention": bool(d.get("calls"))} + + +def a_mosaic(S, s): + q = S["mosaic_sex_loss"]["data"] + call = rr.MOSAIC_CALL.get(q.get("call"), (q.get("call"), ""))[0] + return {"facts": [[q.get("chrom") or "chrY or chrX", call]], "lines": [q.get("reason") or ""] + rr.mosaic_lines(q), + "attention": q.get("call") in ("detected", "gain")} + + +def a_mosaic_mca(S, s): + d = S["mosaic"]["data"] + rows = [[c.get("chrom"), c.get("start"), c.get("end"), c.get("length_mb"), c.get("type"), c.get("cell_fraction")] + for c in d.get("calls") or []] + return {"facts": [["Status", d.get("status")], ["Calls reported", d.get("calls_reported", len(rows))], + ["Smallest cell fraction it can see", d.get("floor")]], + "table": table(["Chromosome", "Start", "End", "Length (Mb)", "Type", "Cell fraction"], rows), + "lines": rr.mosaic_mca_lines(d), "attention": bool(d.get("calls"))} + + +def a_carrier(S, s): + d = S["carrier"]["data"] + if d.get("unavailable"): + return {"lines": rr.carrier_lines(d), "attention": True} + rows = [[c.get("gene"), c.get("variant"), c.get("genotype"), c.get("significance"), c.get("stars"), + c.get("inheritance"), c.get("condition")] for c in d.get("carriers") or []] + two = (d.get("two_hits") or {}).get("genes") or [] + return {"facts": [["Genes on the carrier list", d.get("genes_on_list")], ["Carrier findings", len(rows)], + ["Genes with two hits", ", ".join(g if isinstance(g, str) else g.get("gene", ".") for g in two) + or "none"]], + "table": table(["Gene", "Variant", "Genotype", "Significance", "Stars", "Inheritance", "Condition"], rows), + "lines": rr.two_hit_lines(d.get("two_hits")), "attention": bool(two)} + + +def a_couple(S, s): + d = S["couple"]["data"] + rows = [[lab, x.get("gene"), x.get("condition"), x.get("risk"), x.get("own"), x.get("partner_findings")] + for lab, key in (("Both carry", "shared"), ("X-linked", "x_linked")) for x in d.get(key) or []] + return {"facts": [["Partner", d.get("partner")], ["Genes where both carry a change", len(d.get("shared") or [])], + ["X-linked findings", len(d.get("x_linked") or [])], + ["Genes where only one partner carries a change", + f"{d.get('one_partner', 0)}, unless the other partner carries something the screen does not see"]], + "table": table(["Result", "Gene", "Condition", "Chance for a child", "This person", "Partner"], rows)} + + +def a_paralogs(S, s): + lines = [] + if (S.get("parascopy") or {}).get("state") in ("ok", "stale"): + d = S["parascopy"]["data"] + lines += rr.parascopy_lines(d) + rr.parascopy_locus_lines(d) + rr.cn_interval_lines(d.get("cn_intervals")) + rows = [] + if (S.get("paralogs") or {}).get("state") in ("ok", "stale"): + rows = [[g.get("gene"), g.get("copy_number"), g.get("status"), g.get("detail")] + for g in S["paralogs"]["data"].get("genes") or []] + return {"lines": lines, "table": table(["Gene", "Copies", "Status", "Detail"], rows)} + + +def a_pharmcat(S, s): + d = S["pharmcat"]["data"] + return {"facts": [["PharmCAT report", "Ran"], ["PharmCAT version", d.get("version") or "unknown"], + ["Its own HTML report", "beside it" if d.get("html_report") else "not written"]], + "links": [r"(^|/)pharmcat/[^/]+\.html$"]} + + +def a_cpic(S, s): + d = S["cpic"]["data"] + if d.get("parse_failed"): + return {"lines": ["The PharmCAT report could not be parsed: see the CPIC report."] + + [f"WARNING: {w}" for w in d.get("warnings") or []], "attention": True} + rows = [[g.get("gene"), g.get("diplotype"), f'"{g.get("phenotype")}"' if g.get("phenotype") else ".", g.get("status")] + for g in d.get("genes") or []] + return {"facts": [["Genes with a changed-function phenotype", d.get("non_normal", 0)], + ["Genes with more than one possible result", d.get("ambiguous", 0)], + ["Genes not called", d.get("not_called", 0)]], + "table": table(["Gene", "Diplotype", "Phenotype (CPIC's words)", "Status"], rows), + "lines": [f"WARNING: {w}" for w in d.get("warnings") or []]} + + +def a_cyp2d6(S, s): + c = s.get("cyp2d6") or {} + rows = [[rr.CYP2D6_LABELS.get(k, k), v or "not run"] for k, v in (c.get("calls") or {}).items()] + agree = c.get("agree") + facts = [["pypgx and Cyrius agree", "yes" if agree else "no" if agree is False else "fewer than two usable calls"]] + v = rr.cyp2d6_verdict(c) + if v: + facts.append(["Step 36", plain(v)]) + lines = rr.pypgx_lines(S["pypgx"]["data"]) if (S.get("pypgx") or {}).get("state") in ("ok", "stale") else [] + return {"facts": facts, "table": table(["Caller", "CYP2D6"], rows), "lines": lines, "attention": agree is False} + + +def a_hla(S, s): + d = S["hla"]["data"] + rows = [[l.get("gene"), rr.hla_text(l)] for l in d.get("loci") or []] + return {"facts": [["HLA database", (s.get("databases") or {}).get("hla_database") or "unknown"]], + "table": table(["Gene", "Alleles (T1K quality)"], rows), "lines": rr.hla_derived_lines(d.get("derived"))} + + +def a_locityper(S, s): + d = S["locityper"]["data"] + rows = [[l.get("locus"), l.get("genotype"), l.get("quality"), l.get("filter"), l.get("primary")] + for l in d.get("loci") or []] + return {"facts": [["Loci called", f"{d.get('called', 0)} of {len(d.get('loci') or [])}"], + ["Passing the authors' filter", d.get("passed", 0)]], + "table": table(["Locus", "Panel haplotypes", "Quality", "Filter", "Primary caller"], rows)} + + +def a_ancestry_fine(S, s): + d = S["ancestry_fine"]["data"] + rows = [[lab, r.get("group"), rr.share(r.get("fraction")), f"{rr.share(r.get('low'))} to {rr.share(r.get('high'))}"] + for level, lab in rr.ANCESTRY_FINE_LEVELS for r in rr.ancestry_fine_rows(d, level)] + return {"table": table(["Level", "Group", "Share", "Bootstrap range"], rows), + "facts": [["Chromosome painting", rr.ancestry_fine_painted(d)]]} + + +def a_mt_haplogroup(S, s): + d = S["haplogroup"]["data"] + return {"facts": [["Haplogroup", d.get("haplogroup")], ["Two mtDNA haplogroups in the reads", + rr.contamination_text(d)]], + "explore": [{"name": x.get("name"), "url": x.get("url")} for x in d.get("explore") or []]} + + +def a_y(S, s): + facts, explore, rows = [], [], [] + y = (S.get("y_haplogroup") or {}).get("data") or {} + t = (S.get("y_tree") or {}).get("data") or {} + if y: + facts += [["Haplogroup (Yleaf)", y.get("haplogroup")], ["QC score", y.get("qc_score")]] + explore = [{"name": x.get("name"), "url": x.get("url")} for x in y.get("explore") or []] + if t: + facts.append([f"Placement on {t.get('tree')}", t.get("haplogroup")]) + rows = [[p.get("Level"), p.get("Branch"), p.get("Derived"), p.get("Ancestral"), p.get("No_call")] + for p in t.get("path") or []] + st = (S.get("y_str") or {}).get("data") or {} + if st.get("markers"): + facts.append(["Y-STR markers read", sum(1 for m in st["markers"] if m.get("count"))]) + return {"facts": facts, "explore": explore, + "table": table(["Level", "Branch", "Derived", "Ancestral", "No call"], rows)} + + +def a_archaic(S, s): + d = S["archaic"]["data"] + return {"facts": [["Neanderthal", f"{d.get('neanderthal_percent')}%"], ["Denisovan", f"{d.get('denisovan_percent')}%"]], + "lines": rr.archaic_lines(d)} + + +def a_ancient(S, s): + d = S["ancient"]["data"] + rows = [[r.get("rank"), r.get("group"), r.get("dates"), r.get("country"), r.get("distance")] + for r in (d.get("nearest") or [])[:10]] + out = {"table": table(["Rank", "Group", "Dates", "Country", "Distance"], rows), + "facts": [["Sites used", f"{d.get('sites_used')} of {d.get('panel_sites')}"]]} + svg = d.get("plot_svg") + if isinstance(svg, str) and svg.strip(): + # collect_summary stores it base64-encoded (the report puts it in a data URL as is); raw SVG is encoded here. + import base64 + if svg.lstrip().startswith("<"): + svg = base64.b64encode(svg.encode()).decode() + out["image"] = {"alt": "PCA of ancient genomes with this genome projected", "src": "data:image/svg+xml;base64," + svg} + return out + + +def a_roh(S, s): + d = S["roh"]["data"] + return {"facts": [["Segments", d.get("segments")], ["Total", f"{d.get('total_mb')} Mb"], + ["Largest", f"{d.get('largest_mb')} Mb"], ["F_ROH", d.get("f_roh")], + ["Nearest band", d.get("relationship")]], + "lines": rr.roh_lines(d), "attention": bool(d.get("upd_flag"))} + + +def a_traits(S, s): + d = S["traits"]["data"] + rows = [[r.get("gene"), r.get("trait"), (r.get("genotype") or ".") + (" (ancestry)" if r.get("ancestry") == "yes" else ""), + (r.get("reading") or ".") + (f". {r['note']}" if r.get("note") else "")] + for r in d.get("rows") or [] if r.get("status") == "called" and (r.get("gene") or "").upper() != "APOE"] + lines = [] + for r in d.get("rules") or []: + # The report's genotype rules: several sites read together, with phase and APOL1's G2. + lines.append(f"{r.get('trait')} ({r.get('gene')} {str(r.get('rsids') or '').replace(',', ', ')}): " + f"{rr.rule_result(r)}. {r.get('reading')}") + if r.get("g2"): + lines.append(f"With G2 (rs71785313): {rr.g2_result(r)}. {r['g2'].get('text')} {r['g2'].get('reading')}") + out = {"facts": [["Rows read", d.get("called")], ["No genotype at the position", d.get("not_covered")]], + "table": table(["Gene", "Trait", "Genotype", "Reading"], rows), "lines": lines} + apoe = d.get("apoe") + if apoe: + # Behind its warning, as the report shows it: the page puts it in a closed
. + # collect_summary keeps one dict per APOE row; each reads as the report's text line does. + rows = apoe if isinstance(apoe, list) else [apoe] + text = "; ".join(f"{r.get('trait')} ({r.get('rsid')}): {r.get('genotype')}. {r.get('reading')}" + if isinstance(r, dict) else cell(r) for r in rows) + out["hidden"] = {"summary": rr.APOE_WARNING, "text": plain(text)} + return out + + +def a_prs(S, s): + d = S["prs"]["data"] + if not d.get("adjusted"): + # The report shows a percentile only when it is ancestry-adjusted. + return {"facts": [["Scores", len(d.get("scores") or [])]], + "lines": ["Raw scores only: without the ancestry panel no percentile is shown (docs/25-prs.md)."]} + rows = [[r.get("condition"), r.get("pgs_id"), rr.prs_percentile(r) or ".", f"{r.get('matched')}/{r.get('total')}", + rr.prs_effect(r) or "."] for r in d.get("scores") or []] + return {"table": table(["Condition", "Score", "Percentile", "Variants matched", "Effect"], rows), + "lines": [rr.prs_note(d)]} + + +def a_bloodtype(S, s): + d = S["bloodtype"]["data"] + rows = [[x.get("system"), x.get("phenotype"), x.get("genotype"), x.get("confidence")] for x in d.get("systems") or []] + return {"facts": [["ABO", d.get("abo")], ["RhD", d.get("rhd")]], + "table": table(["System", "Predicted phenotype", "Genotype", "Confidence"], rows)} + + +def a_telomere(S, s): + d = S["telomere"]["data"] + return {"facts": [["Telomere content", f"{d.get('tel_content')} ({rr.TELOMERE_UNIT})"]], + "lines": rr.telomere_lines(d), "attention": bool(d.get("low_reads"))} + + +def a_viral(S, s): + d = S["viral"]["data"] + rows = [[x.get("virus"), x.get("reads"), x.get("breadth"), x.get("copies_per_cell"), x.get("call")] + for x in d.get("found") or []] + return {"facts": [["HHV-6", d.get("hhv6")], ["Genomes searched", d.get("genomes")]], + "table": table(["Virus", "Reads", "Breadth", "Copies per cell", "Call"], rows)} + + +def a_consumer(S, s): + d = S["consumer_export"]["data"] + return {"facts": [["Sites", d.get("sites")], ["Called", d.get("called")], ["No call", d.get("no_call")]], + "lines": rr.consumer_export_lines(d)} + + +ADAPTERS = { + "qc": a_qc, "callability": a_callability, "variants": a_variants, "clinvar": a_clinvar, + "clinical-filter": a_clinical_filter, "cpsr": a_cpsr, "slivar": a_slivar, "exomiser": a_exomiser, + "structural-variants": a_structural, "mei": a_mei, "expansions": a_expansions, "vntyper": a_vntyper, + "mito": a_mito, "lpa": a_lpa, "chip": a_chip, "mosaic": a_mosaic, "mosaic-mca": a_mosaic_mca, + "carrier-list": a_carrier, "couple": a_couple, "paralogs": a_paralogs, "pharmcat": a_pharmcat, "cpic": a_cpic, + "cyp2d6": a_cyp2d6, "hla": a_hla, "locityper": a_locityper, "ancestry-fine": a_ancestry_fine, + "mt-haplogroup": a_mt_haplogroup, "y-chromosome": a_y, "archaic": a_archaic, "ancient": a_ancient, "roh": a_roh, + "traits-panel": a_traits, "prs": a_prs, "bloodtype": a_bloodtype, "telomere": a_telomere, "viral": a_viral, + "consumer-export": a_consumer, +} +# Report charts (bin/report_charts.py) drawn inside a card: chart id -> section id. +CHART_SECTION = {"clinvar": "clinvar", "genome": "clinvar", "mito": "mito", "ancestry": "ancestry-fine", "prs": "prs"} + + +def caveats(sid): + return [getattr(rr, n) for n in rr.SECTION_NOTES.get(sid, ()) if isinstance(getattr(rr, n, None), str)] + + +def section_state(secs): + st = {x.get("state") or "missing" for x in secs} + if st & {"failed", "unreadable"}: + return "failed" if not st & {"ok", "stale"} else "part" + if "stale" in st: + return "stale" + if "ok" in st: + return "incomplete" if "missing" in st else "ran" + return "missing" + + +def _links(patterns, files): + out = [] + for p in patterns or []: + rx = re.compile(p) + out += sorted(f for f in files if rx.search(f) and f not in out) + return out[:4] + + +def card(sid, title, keys, summary, files): + S = summary.get("sections") or {} + secs = [S.get(k) or {"state": "missing"} for k in keys] + state = section_state(secs) + steps = [] + for k, sec in zip(keys, secs): + st = sec.get("step") or rr.STEP_OF.get(k) + if st and st not in steps: + steps.append(st) + cav = caveats(sid) + out = {"id": sid, "title": title, "keys": list(keys), "steps": steps, "state": state, "anchor": sid, + "attention": False, "headline": None, "facts": [], "table": None, "lines": [], "caveat": cav, + "empty_text": EMPTY.get(sid), "links": [], "notes": []} + for k, sec in zip(keys, secs): + if sec.get("state") in ("failed", "unreadable", "stale"): + out["notes"].append(f"{sec.get('title') or k}: {plain(rr.stale_note(sec))}") + out["attention"] = True + if state == "missing": + return out + tones = [] + for k, sec in zip(keys, secs): + if sec.get("state") in ("ok", "stale"): + text, tone = _headline_tone(k, sec, summary) + if text and not out["headline"] and not text.endswith("finished, see the report"): + out["headline"] = text + tones.append(tone) + if "attention" in tones: + out["attention"] = True + if out["headline"]: + # The step headline carries its caveat (the Progress tab shows no other); the card prints it below. + for c in sorted({plain(c) for c in cav}, key=len, reverse=True): + if out["headline"].endswith(" " + c): + out["headline"] = out["headline"][:-len(c) - 1].rstrip() + break + fn = ADAPTERS.get(sid) + if fn and state not in ("failed",): + try: + got = fn(S, summary) or {} + except (KeyError, TypeError, ValueError, AttributeError, IndexError, ZeroDivisionError) as e: + got = {"lines": [f"This card could not be drawn ({type(e).__name__}); the report shows the section."]} + out["facts"] = [[plain(a), plain(cell(b))] for a, b in got.get("facts") or []] + out["table"] = got.get("table") + notes = {plain(c) for c in cav} | {plain(n) for n in rr.caveat_notes()} + out["lines"] = [plain(x) for x in got.get("lines") or [] if plain(x) and plain(x) not in notes + and plain(x) != out["headline"]] + out["attention"] = out["attention"] or bool(got.get("attention")) + out["links"] = _links(got.get("links"), files) + for k in ("widget", "explore", "image", "hidden", "viewer"): + if got.get(k): + out[k] = got[k] + return out + + +def build(sample, summary, files=()): + """results.json of one person from a summary.json; files: the run's paths the report links (for links).""" + summary = summary or {} + files = set(files or ()) + S = summary.get("sections") or {} + chapters = [] + for cid, ctitle, question, secs in rr.CHAPTERS: + cards = [card(sid, title, keys, summary, files) for sid, title, keys in secs] + chapters.append({"key": cid, "title": ctitle, "question": question, "sections": cards, + "attention": sum(1 for c in cards if c["attention"])}) + sexc = (S.get("sex_check") or {}).get("data") or {} + person = {"sample": sample, "declared_sex": (summary.get("run") or {}).get("declared_sex"), + "inferred_sex": sexc.get("inferred_sex"), "karyotype": sexc.get("karyotype")} + return {"schema_version": SCHEMA, "sample": sample, "generated_utc": summary.get("generated_utc"), + "person": person, "chapters": chapters, + "attention": sum(c["attention"] for c in chapters), + "not_assessed": [plain(x) for x in summary.get("not_assessed") or []], + "not_run": [plain(x) for x in summary.get("not_run") or []], + "new_since": summary.get("new_since") or None, + "charts": CHART_SECTION} + + +def attention_count(summary): + """The number of cards worth a look, for the home page (cheap: no file read).""" + return build("", summary)["attention"] + + +def strings(obj): + """Every string in a JSON value, with its key path: the wording gate's input.""" + if isinstance(obj, dict): + for k, v in obj.items(): + for p, s in strings(v): + yield (k,) + p, s + elif isinstance(obj, list): + for v in obj: + yield from strings(v) + elif isinstance(obj, str): + yield (), obj + + +def wording_problems(obj, allow=("src", "url", "caveat", "summary")): + """[(path, text)] of the strings that would read as an all-clear. Allowed: data URLs and links, the + caveats (render_report's constants word for word, checked against them separately), and a quoted + tool term (CPIC's "Normal Metabolizer", Stranger's "normal").""" + out = [] + for path, s in strings(obj): + if path and path[-1] in allow: + continue + t = re.sub(r'"[^"]*"', "", s) + if FORBIDDEN.search(t): + out.append((".".join(map(str, path)), s[:160])) + return out diff --git a/bin/owngenome_live.py b/bin/owngenome_live.py index b8576a73..eaa7a993 100755 --- a/bin/owngenome_live.py +++ b/bin/owngenome_live.py @@ -19,9 +19,11 @@ browser (--bind 127.0.0.1, --port 8787) serves the pages, after a login when a password hash is set: GET/POST /login, POST /logout - GET / the runs under --root + GET / the people under --root, by family (assets/live/home.html) + GET /runs.json each run's progress and each family's members GET /assets/, /assets/plugins/ - GET /runs// the live page (assets/live/index.html) + GET /runs// a person's page (assets/live/index.html), or a family's + (assets/live/family.html) GET /runs//state.json bin/run_state.py's state, plus the headline of each finished section and what the plugins add @@ -86,6 +88,8 @@ "frame-ancestors 'none'") RUN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") # validate_sample in scripts/lib/common.sh ASSET_RE = re.compile(r"^(?:plugins/)?[A-Za-z0-9][A-Za-z0-9._-]*$") +# The page templates of assets/live: served filled in, never as an asset. +PAGES = ("index.html", "login.html", "home.html", "family.html") TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", ".txt": "text/plain; charset=utf-8", ".tsv": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".pdf": "application/pdf"} @@ -352,7 +356,8 @@ def headline(key, sec, summary=None): text, attention = fn(sec.get("data") or {}, summary or {}) except (KeyError, TypeError, ValueError, AttributeError, IndexError): text, attention = None, False - return (text or f"{title}: finished, see the report"), ("attention" if attention else "info") + # Markdown bold (the ROH helper's first line) reads as literal asterisks on a page. + return (text or f"{title}: finished, see the report").replace("**", ""), ("attention" if attention else "info") # --- the plugin interface --------------------------------------------------------------------- @@ -518,6 +523,7 @@ def __init__(self, app, cfg, password_hash): self.sessions, self.failures, self.lock = {}, {}, threading.Lock() self.streams = threading.BoundedSemaphore(cfg.max_streams) self.headline_cache = {} + self.attention_cache = {} self.allowed = set(LOOPBACK_HOSTS) | {h.lower() for h in cfg.allowed_host or []} if cfg.bind not in ("0.0.0.0", "::"): self.allowed.add(cfg.bind.strip("[]").lower()) @@ -593,7 +599,8 @@ def runs(self): for fn in self.app.run_kinds: for r in fn(self.app) or []: if valid_run(r.get("name", "")) and r["name"] not in out: - out[r["name"]] = {"name": r["name"], "dir": r.get("dir"), "kind": r.get("kind", "other")} + # Keys beyond name, dir and kind (a family's members) pass through to /runs.json. + out[r["name"]] = dict(r, name=r["name"], dir=r.get("dir"), kind=r.get("kind", "other")) return out @staticmethod @@ -604,9 +611,10 @@ def inside(path, top): def run(self, name): return self.runs().get(name) if valid_run(name) else None - def state(self, run): + def base_state(self, run, events=None): + """run_state's state of the run's newest run, without headlines or plugins (the home page's view).""" d = run["dir"] or os.path.join(self.root, run["name"]) - events = self.store.events(run["name"]) + events = self.store.events(run["name"]) if events is None else events cur = current_run(events) # run_state.collect, on the current run's messages only (an earlier run's 'completed' would # mark this one finished, and its tasks would mark this run's steps done). @@ -625,39 +633,101 @@ def state(self, run): if run_finished(events) and st["run"]["state"] == "running": st["run"]["state"] = "finished" st["events"] = len(events) # the page paints a message ahead of state.json only past this id + return st + + def state(self, run): + d = run["dir"] or os.path.join(self.root, run["name"]) + events = self.store.events(run["name"]) + st = self.base_state(run, events) # The report's anchor of each section (bin/render_report.py's chapters), for the step detail's link. sec_of = getattr(_rr(), "SECTION_OF", {}) st["anchors"] = {k: sec_of[k] for s in st["steps"] for k in s["sections"] if k in sec_of} st["headlines"] = self.headlines(run["name"], d, st) + # Changes when step 24 re-writes summary.json with no new step done: the person page fetches + # results.json again on it. + stamp = self.file_stamp(os.path.join(d, "summary.json")) + st["results_stamp"] = "%d-%d" % stamp if stamp else None st["plugins"] = {} for name, fn in self.app.state_hooks: st["plugins"][name] = fn(d, st, events) return st - def headlines(self, sample, d, st): - """{section: {text, tone, step}} for the sections of the steps that are done. The summary - is read again only when the set of done steps or the message count changes.""" + def summary(self, sample, d, st): + """The run's summary for the steps that are done: its summary.json when that is newer than + the end of every done step (step 24 wrote it after them), else collected now. Read again only + when the set of done steps or the message count changes; None while nothing is done.""" done = tuple(s["step"] for s in st["steps"] if s["state"] == "done") - key = (sample, done, st.get("events")) # a section's file can land after its step's last message + # A section's file can land after its step's last message, and a re-made report rewrites + # summary.json with no new message: its mtime and size are part of the key. + key = (sample, done, st.get("events"), self.file_stamp(os.path.join(d, "summary.json")) if d else None) hit = self.headline_cache.get(sample) if hit and hit[0] == key: + return hit[2] + summary = None + if done and d and os.path.isdir(d): + summary = self.summary_file(d, st) + if summary is None: + import collect_summary + try: + summary = collect_summary.collect(sample, d) + except Exception as e: # a reader bug must not take the page down + print(f"owngenome_live: summary of {sample} not read: {type(e).__name__}", file=sys.stderr) + self.headline_cache[sample] = (key, None, summary) + return summary + + @staticmethod + def file_stamp(p): + try: + st = os.stat(p) + return st.st_mtime_ns, st.st_size + except OSError: + return None + + @staticmethod + def summary_file(d, st): + """/summary.json when it is newer than every done step's end, else None. A summary.json + that links out of the run folder is never read.""" + p = os.path.join(d, "summary.json") + if not Live.inside(p, os.path.realpath(d)): + return None + try: + mtime = os.path.getmtime(p) + except OSError: + return None + ends = [s.get("ended_utc") for s in st["steps"] if s["state"] == "done"] + for e in ends: + try: + if not e or datetime.strptime(e[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc).timestamp() > mtime: + return None + except ValueError: + return None + try: + with open(p) as f: + s = json.load(f) + return s if isinstance(s, dict) and isinstance(s.get("sections"), dict) else None + except (OSError, ValueError): + return None + + def headlines(self, sample, d, st): + """{section: {text, tone, step}} for the sections of the steps that are done.""" + self.summary(sample, d, st) + # The headlines come from the cached entry's own summary and go back only into that same + # entry, so a request that saw another state never pairs its headlines with this key. + hit = self.headline_cache.get(sample) + if hit and hit[1] is not None: return hit[1] + summary = hit[2] if hit else None out = {} - if done and os.path.isdir(d): - import collect_summary - try: - summary = collect_summary.collect(sample, d) - except Exception as e: # a reader bug must not take the page down - print(f"owngenome_live: summary of {sample} not read: {type(e).__name__}", file=sys.stderr) - summary = None + if summary: for s in st["steps"]: - if s["state"] != "done" or not summary: + if s["state"] != "done": continue for k in s["sections"]: h = headline(k, summary["sections"].get(k), summary) if h: out[k] = {"text": h[0], "tone": h[1], "step": s["step"]} - self.headline_cache[sample] = (key, out) + if hit and self.headline_cache.get(sample) is hit: + self.headline_cache[sample] = (hit[0], out, hit[2]) return out def run_files(self, run): @@ -702,11 +772,69 @@ def sole_html(self, run, folder, files=None): and Live.inside(os.path.join(p, f), top) and Live.inside(os.path.join(p, f), self.root)] return pages[0] if len(pages) == 1 else None - def index_page(self): - assets = self.cfg.assets - with open(os.path.join(assets, "index.html")) as f: + def index_page(self, kind=None): + """The page template of a run: family.html for a family, index.html (a person) otherwise.""" + name = "family.html" if kind == "family" and os.path.isfile(os.path.join(self.cfg.assets, "family.html")) \ + else "index.html" + with open(os.path.join(self.cfg.assets, name)) as f: return f.read() + def attention(self, d): + """(count of cards worth a look, mtime) from /summary.json alone, cached by its mtime; + (None, None) without one. The home page reads no result file beyond that.""" + p = os.path.join(d, "summary.json") if d else None + if not p or not self.inside(p, os.path.realpath(d)) or not self.inside(p, self.root): + return None, None # a summary.json linking out of its run is never read + try: + mtime = os.path.getmtime(p) + except (OSError, TypeError): + return None, None + stamp = self.file_stamp(p) + hit = self.attention_cache.get(p) + if hit and hit[0] == stamp: + return hit[1], mtime + n = None + try: + import live_results + with open(p) as f: + n = live_results.attention_count(json.load(f)) + except Exception as e: # one bad file must not take the home page down + print(f"owngenome_live: {os.path.basename(os.path.dirname(p))}/summary.json not read: {type(e).__name__}", + file=sys.stderr) + if n is not None: # a failed read is tried again on the next request + self.attention_cache[p] = (stamp, n) + return n, mtime + + def runs_index(self): + """/runs.json: each run's progress, and the families with their members, for the home page. + No result beyond the attention count; no path.""" + runs = self.runs() + # Forget the cached results of runs that are gone, so the caches hold only the runs there are. + for k in [k for k in list(self.headline_cache) if k not in runs]: + self.headline_cache.pop(k, None) + keep = {os.path.join(r["dir"] or os.path.join(self.root, n), "summary.json") for n, r in runs.items()} + for k in [k for k in list(self.attention_cache) if k not in keep]: + self.attention_cache.pop(k, None) + out = [] + for name, r in sorted(runs.items()): + item = {"name": name, "kind": r["kind"], "url": f"/runs/{name}/"} + if r["kind"] != "family": + st = self.base_state(r) + planned = [s for s in st["steps"] if s["state"] != "not planned"] + item.update(state=st["run"]["state"], started_utc=st["run"].get("started_utc"), + finished_utc=st["run"].get("finished_utc"), declared_sex=st["run"].get("declared_sex"), + planned=len(planned), done=sum(1 for s in planned if s["state"] == "done"), + running=sum(1 for s in planned if s["state"] == "running"), + failed=sum(1 for s in planned if s["state"] == "failed")) + # A run known from its messages alone still has its folder under the root once step 24 wrote there. + item["attention"], mtime = self.attention(r["dir"] or os.path.join(self.root, name)) + item["results"] = mtime is not None + else: + item["members"] = r.get("members") or [] + out.append(item) + fams = [{"name": x["name"], "url": x["url"], "members": x["members"]} for x in out if x["kind"] == "family"] + return {"runs": [x for x in out if x["kind"] != "family"], "families": fams} + class BrowserHandler(BaseHTTPRequestHandler): server_version = "owngenome-live" @@ -856,6 +984,8 @@ def handle_any(self, method): return self.deny(401) if path == "/": return self.index() + if path == "/runs.json": + return self.runs_json() m = re.fullmatch(r"/runs/([^/]+)(/.*)?", path) if not m: return self.deny(404) @@ -921,30 +1051,35 @@ def asset(self, name): return self.deny(404) p = os.path.join(self.live.cfg.assets, name) top = os.path.realpath(self.live.cfg.assets) - if not os.path.isfile(p) or not Live.inside(p, top) or name in ("index.html", "login.html"): + if not os.path.isfile(p) or not Live.inside(p, top) or name in PAGES: return self.deny(404) with open(p, "rb") as f: self.send_body(200, TYPES.get(os.path.splitext(p)[1], "application/octet-stream"), f.read()) def index(self): + """assets/live/home.html: people grouped by family, drawn by home.js from /runs.json. The plain + list of runs in it is what a browser without scripts shows.""" runs = self.live.runs() items = "\n".join(f'
  • {html.escape(n)} ' f'{html.escape(r["kind"])}
  • ' for n, r in sorted(runs.items())) self.log("index", status=200) - page = ('\n' - 'Runs' - '

    Runs

    ' - '
    ' - f'
      {items or "
    • No run under this folder yet.
    • "}
    \n') + with open(os.path.join(self.live.cfg.assets, "home.html")) as f: + page = f.read() + page = (page.replace("{{assets}}", "/assets").replace("{{data}}", "/runs.json") + .replace("{{runs}}", items or "
  • No run under this folder yet.
  • ")) self.send_body(200, TYPES[".html"], page) + def runs_json(self): + self.log("runs", status=200) + self.send_json(self.live.runs_index()) + def run_page(self, run): plugins_dir = os.path.join(self.live.cfg.assets, "plugins") js = sorted(f for f in os.listdir(plugins_dir) if ASSET_RE.match(f) and f.endswith(".js")) \ if os.path.isdir(plugins_dir) else [] tags = "\n".join(f'' for f in js) - page = (self.live.index_page().replace("{{assets}}", "/assets").replace("{{run}}", html.escape(run["name"])) - .replace("{{mode}}", "live").replace("{{plugins}}", tags)) + page = (self.live.index_page(run.get("kind")).replace("{{assets}}", "/assets") + .replace("{{run}}", html.escape(run["name"])).replace("{{mode}}", "live").replace("{{plugins}}", tags)) self.log("run", run["name"], status=200) self.send_body(200, TYPES[".html"], page) diff --git a/bin/owngenome_live_family.py b/bin/owngenome_live_family.py index 7f1f20ef..17ec9f87 100644 --- a/bin/owngenome_live_family.py +++ b/bin/owngenome_live_family.py @@ -11,6 +11,12 @@ GET /family// pedigree.svg (drawn), or one of the family's result files listed in state.json; any other name is 404 + GET /runs//family.json the family page's data (assets/live/family.html): + the state below, the pedigree's layout (so each + person is a link), the relationship and shared- + segment rows with their measured numbers, each + couple's gene rows, and each member's run progress + and attention count (from its summary.json only) state.json gets plugins.family: {"kind": "family", ...} on a family's page, {"kind": "member", "families": [...]} on the page of a sample in a family, @@ -61,8 +67,9 @@ def family_dirs(root): def read_tsv(path): - """Rows of a tab-separated file with a header line, as dicts; [] when it is not there.""" - if not os.path.isfile(path): + """Rows of a tab-separated file with a header line, as dicts; [] when it is not there or is a + link out of its own folder (family.json serves only what the family folder itself holds).""" + if not os.path.isfile(path) or not inside(path, os.path.dirname(os.path.abspath(path))): return [] rows = [] with open(path, errors="replace") as f: @@ -138,6 +145,74 @@ def link(f): "files": files} +def layout_of(order, members): + """The pedigree drawing's geometry (bin/pedigree_svg.py layout) as data, so the page draws each + person as a link: people with their centre, partner lines and the descent lines of each sibship.""" + pos, gen, pairs, widest, ngen = pedigree_svg.layout(order, members) + h = pedigree_svg.SIZE / 2 + people = [{"sample": s, "x": round(pos[s][0], 1), "y": round(pos[s][1], 1), "gen": gen[s], "sex": members[s]["sex"], + "sequenced": bool(members[s]["sequenced"])} for s in order] + lines = [] + for a, b in pairs: + (xa, ya), (xb, yb) = sorted((pos[a], pos[b])) + lines.append([xa + h, ya, xb - h, yb]) + sibships = {} + for s in order: + f, mo = members[s]["father"], members[s]["mother"] + f, mo = (f if f in pos else None), (mo if mo in pos else None) + if f or mo: + sibships.setdefault((f, mo), []).append(s) + for (f, mo), kids in sibships.items(): + if f and mo: + ox, oy = (pos[f][0] + pos[mo][0]) / 2, (pos[f][1] + pos[mo][1]) / 2 + else: + ox, oy = pos[f or mo][0], pos[f or mo][1] + h + bar = min(pos[k][1] for k in kids) - h - 22 + xs = [pos[k][0] for k in kids] + [ox] + lines += [[ox, oy, ox, bar], [min(xs), bar, max(xs), bar]] + lines += [[pos[k][0], bar, pos[k][0], pos[k][1] - h] for k in kids] + width = 2 * pedigree_svg.MARGIN + pedigree_svg.SIZE + (widest - 1) * pedigree_svg.STEP_X + height = pedigree_svg.TOP + pedigree_svg.SIZE + (ngen - 1) * pedigree_svg.STEP_Y + 30 + return {"width": width, "height": height, "size": pedigree_svg.SIZE, "people": people, + "lines": [[round(v, 1) for v in ln] for ln in lines]} + + +def family_page(name, d): + """family.json without the member runs: the state, the layout, the full pair rows and couple rows.""" + st = family_state(name, d) + import render_report # the couple screen's caveat, word for word + out = {"state": st, "layout": None, "relationships": [], "ibd_pairs": [], "couples": {}, + "couple_note": render_report.COUPLE_NOTE} + if st.get("error"): + return out + order, members = members_of(d) + out["layout"] = layout_of(order, members) + keep = ("sample_a", "sample_b", "declared", "expected_relatedness", "relatedness", "ibs0", "ibs2", "sites", + "verdict", "reason") + out["relationships"] = [{k: r.get(k) for k in keep} for r in read_tsv(os.path.join(d, "relationships.tsv"))] + keep = ("sample_a", "sample_b", "declared", "segments", "shared_cm", "ibd2_cm", "longest_cm", "relatedness", + "degree") + out["ibd_pairs"] = [{k: r.get(k) for k in keep} for r in read_tsv(os.path.join(d, "ibd_pairs.tsv"))] + order_of = {"shared": 0, "x_linked": 1, "one_partner": 2} + for a, b, base in couple_names(order, members): + rows = read_tsv(os.path.join(d, base + ".tsv")) + keep = ("gene", "inheritance", "condition", "result", "offspring_risk", "partner_a", "findings_a", + "partner_b", "findings_b", "explanation") + rows = sorted(({k: r.get(k) for k in keep} for r in rows), key=lambda r: order_of.get(r.get("result"), 3)) + out["couples"]["%s %s" % (a, b)] = rows + # Step 38 writes the verdict "ok" for a pair that matches its declared relation. The page says that + # in words: no word on the page reads as an all-clear (state.json keeps the file's own value). + out["state"] = dict(st, relationships=[dict(r, verdict=verdict_words(r.get("verdict"))) + for r in st.get("relationships") or []]) + for r in out["relationships"]: + r["verdict"] = verdict_words(r.get("verdict")) + return out + + +def verdict_words(v): + return "matches the declared relation" if (v or "").strip().lower() == "ok" else v + + def member_state(root, sample): fams = [] for name, d in family_dirs(root): @@ -155,7 +230,40 @@ def root(): return os.path.realpath(app.config.root) def runs(app_): - return [{"name": n, "dir": d, "kind": "family"} for n, d in family_dirs(root())] + out = [] + for n, d in family_dirs(root()): + try: + order, members = members_of(d) + people = [{"sample": s, "sex": members[s]["sex"], "father": members[s]["father"], + "mother": members[s]["mother"], "partner": members[s]["partner"], + "sequenced": members[s]["sequenced"]} for s in order] + except (OSError, ValueError): + people = [] + out.append({"name": n, "dir": d, "kind": "family", "members": people}) + return out + + def page_data(req, m): + name = m.group(1) + d = req.run_dir(name) + if not d or not is_family(d) or not inside(d, root()): + app.log("family", name, "family.json", 404) + return req.send_body(404, "text/plain; charset=utf-8", "not found\n") + out = family_page(name, d) + live = getattr(req, "live", None) + runs = {} + for p in out["state"].get("members") or []: + r = live.run(p["sample"]) if live else None + if not r or r.get("kind") == "family": + continue + st = live.base_state(r) + planned = [s for s in st["steps"] if s["state"] != "not planned"] + n, mtime = live.attention(r["dir"] or os.path.join(live.root, r["name"])) + runs[p["sample"]] = {"url": "/runs/%s/" % p["sample"], "state": st["run"]["state"], + "planned": len(planned), "done": sum(1 for s in planned if s["state"] == "done"), + "attention": n, "results": mtime is not None} + out["runs"] = runs + app.log("family", name, "family.json", 200) + return req.send_json(out) def hook(run_dir, state, events): if not run_dir: @@ -191,3 +299,4 @@ def serve(req, m): app.run_kind(runs) app.state_hook("family", hook) app.route("GET", r"/family/(%s)/(%s)" % (NAME, NAME), serve) + app.route("GET", r"/runs/(%s)/family\.json" % NAME, page_data) diff --git a/bin/owngenome_live_results.py b/bin/owngenome_live_results.py new file mode 100644 index 00000000..1684cf04 --- /dev/null +++ b/bin/owngenome_live_results.py @@ -0,0 +1,51 @@ +"""owngenome_live_results.py: a person's results on the live site, a plugin of bin/owngenome_live.py. + + GET /runs//results.json bin/live_results.py's build() of the run's summary: the + report's seven chapters, one card per section with its + facts, its rows, its caveats and its attention flag + +The summary is the one the server already reads for the step headlines +(Live.summary): the run's summary.json when step 24 wrote it after every done +step, else collect_summary.collect() of the folder, read again only when a step +ends. A reader error gives no cards and a note, never a 500. The raw +summary.json is never served: no run_file glob is added, and results.json holds +what the report prints. Every request writes one access-log row. +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +NAME = r"[A-Za-z0-9][A-Za-z0-9._-]*" + + +def register(app): + def results(req, m): + live = req.live + run = live.run(m.group(1)) + if not run or run.get("kind") == "family": + app.log("results", m.group(1), "-", 404) + return req.send_body(404, "text/plain; charset=utf-8", "not found\n") + name = run["name"] + d = run["dir"] or os.path.join(live.root, name) + out = {"schema_version": 1, "sample": name, "chapters": [], "note": None} + try: + import live_results + st = live.base_state(run) + summary = live.summary(name, d, st) + if summary: + files = live.run_files(run) if run["dir"] else set() + out = live_results.build(name, summary, files) + else: + out["note"] = "No step has finished yet: the results appear here as the steps end." + except Exception as e: # a reader bug must not take the page down + print(f"owngenome_live: results of {name} not built: {type(e).__name__}", file=sys.stderr) + out = {"schema_version": 1, "sample": name, "chapters": [], + "note": f"The results could not be read ({type(e).__name__}); the report and the Progress tab " + "still work."} + app.log("results", name, "-", 200) + return req.send_json(out) + + app.route("GET", r"/runs/(%s)/results\.json" % NAME, results) + diff --git a/bin/render_report.py b/bin/render_report.py index 654b450c..0e17c2db 100755 --- a/bin/render_report.py +++ b/bin/render_report.py @@ -90,6 +90,35 @@ def ancient_lines(d): "only. See a genetic counsellor before acting on a result, or on its absence (docs/39-couple.md).") PRS_ANCESTRY_NOTE = ("Most PRS scores were developed in European-ancestry populations and predict less well for " "other ancestries, even with an ancestry-adjusted percentile.") +# The caveats of the sections that had none, printed by both reports and the live site. +PHARMCAT_NOTE = ("PharmCAT calls star alleles and phenotypes from the variants this genome holds in the genes it " + "covers. A variant it cannot see (a deletion, a duplication, a hybrid gene outside CYP2D6) is not in " + "its answer. Its drug guidance is for a prescriber to read with you: never change a medicine on " + "this report alone.") +CPIC_NOTE = ("CPIC recommendations follow the phenotype PharmCAT called. They are guidance for prescribers, not a " + "prescription. A gene that was not called has no recommendation here, and that is not the same as a " + "typical response.") +PGX_CALLERS_NOTE = ("CYP2D6 sits beside its look-alike CYP2D7, and short reads often cannot tell them apart, so " + "pypgx, Cyrius and step 36 can disagree or give no call. A disagreement or a missing call means " + "the gene was not read reliably here; a clinical CYP2D6 test settles it.") +CPSR_NOTE = ("CPSR grades variants in cancer predisposition genes with ACMG rules. A pathogenic or likely pathogenic " + "row is something to take to a clinical geneticist, not a diagnosis. No row does not rule out an " + "inherited cancer risk: large deletions, genes outside its panel and family history are not part of " + "this screen.") +VNTYPER_NOTE = ("The MUC1 repeat is long and GC-rich, and short reads cover it unevenly. VNtyper looks only for " + "known frameshifts in it: a negative result lowers the chance of those and does not rule out " + "ADTKD-MUC1, and a positive one needs a clinical test (SNaPshot for dupC) to confirm it.") +SV_CALLERS_NOTE = ("Short reads find large deletions and duplications better than insertions and inversions, and " + "the callers disagree on many events. The totals count every call, most of them common in the " + "population; only the AnnotSV class 4 and 5 rows are graded for disease.") +COVERAGE_NOTE = ("Mean depth is an average over the genome: some genes get far fewer reads, and the gene " + "callability card lists those. The sex, identity and contamination checks catch a swapped or " + "mixed sample, not every lab error.") +VARIANTS_NOTE = ("Counts of the calls DeepVariant made against the reference. A whole genome usually holds 4 to 5 " + "million, most of them common. PASS calls passed the caller's filter; a count says nothing about " + "health on its own.") +LPA_NOTE = ("The KIV-2 repeat count from read depth is a research estimate, not an Lp(a) level: the level is a " + "blood test, and the count explains only part of the difference between people.") def combined_cell(h): @@ -393,6 +422,9 @@ def pgx_copy_number(S): MISSING = {"title": "", "state": "missing", "note": None, "data": {}} +SEX_CHECK_AGREE = "declared and measured sex agree" + + def freemix_text(q): """FREEMIX with its verdict, as both reports print it.""" if q.get("contamination") == "not_run" or not q.get("freemix"): @@ -886,6 +918,48 @@ def annotsv_lines(d): ) # The section id of each summary key. SECTION_OF = {k: sid for _, _, _, secs in CHAPTERS for sid, _, keys in secs for k in keys} +# The caveats of each section, by constant name, in the order the card shows them: the first is the +# one the live site shows under a card's title. caveat_notes() is unkeyed, so the live site +# (bin/live_results.py) reads this map; tests/test_live_results.py checks every section id has one. +SECTION_NOTES = { + "qc": ("COVERAGE_NOTE", "KARYOTYPE_NOTE"), + "callability": ("CALLABILITY_NOTE",), + "variants": ("VARIANTS_NOTE",), + "clinvar": ("CLINVAR_FREQ_NOTE", "CDNA_SCAN_NOTE", "DEEP_INTRONIC_NOTE"), + "clinical-filter": ("ACMG_NOTE",), + "cpsr": ("CPSR_NOTE",), + "slivar": ("COMPHET_NOTE",), + "exomiser": ("EXOMISER_NOTE",), + "structural-variants": ("SV_CALLERS_NOTE", "ANNOTSV_NOTE", "CNVPYTOR_NOTE"), + "mei": ("MEI_NOTE",), + "expansions": ("EH_NOTE", "NOT_GRADED_NOTE", "RFC1_NOTE"), + "vntyper": ("VNTYPER_NOTE",), + "mito": ("MITO_NOTE", "MITO_POLYC_NOTE", "MITO_DELETION_NOTE", "MITO_COPIES_NOTE"), + "lpa": ("LPA_NOTE",), + "chip": ("CHIP_NOTE",), + "mosaic": ("MOSAIC_NOTE",), + "mosaic-mca": ("MCA_NOTE",), + "carrier-list": ("CARRIER_NOTE",), + "couple": ("COUPLE_NOTE",), + "paralogs": ("PARASCOPY_NOTE", "PARALOG_NOTE", "PARASCOPY_LOCI_NOTE"), + "pharmcat": ("PHARMCAT_NOTE",), + "cpic": ("CPIC_NOTE",), + "cyp2d6": ("PGX_CALLERS_NOTE",), + "hla": ("HLA_NOTE", "HLA_DERIVED_NOTE", "KIR_NOTE"), + "locityper": ("LOCITYPER_NOTE",), + "ancestry-fine": ("ANCESTRY_FINE_NOTE",), + "mt-haplogroup": ("EXPLORE_NOTE",), + "y-chromosome": ("Y_TREE_NOTE", "YSTR_NOTE", "EXPLORE_NOTE"), + "archaic": ("ARCHAIC_NOTE",), + "ancient": ("ANCIENT_NOTE",), + "roh": ("ROH_NOTE", "ROH_UPD_NOTE", "ROH_F_NOTE", "ROH_BAND_NOTE"), + "traits-panel": ("TRAITS_NOTE",), + "prs": ("PRS_ANCESTRY_NOTE",), + "bloodtype": ("BLOODTYPE_NOTE",), + "telomere": ("TELOMERE_NOTE",), + "viral": ("VIRAL_NOTE",), + "consumer-export": ("CONSUMER_NOTE",), +} def section_keys(sid): @@ -974,7 +1048,8 @@ def head(key, title=None): if sqc["state"] in ("ok", "stale"): q = sqc["data"] w(f" Sex from the reads (somalier): {q.get('inferred_sex')}" - + (" ** DOES NOT MATCH the declared sex **" if q.get("sex_check") == "mismatch" else "")) + + (" ** DOES NOT MATCH the declared sex **" if q.get("sex_check") == "mismatch" else "") + + (f" ({SEX_CHECK_AGREE})" if q.get("sex_check") == "ok" else "")) if q.get("sex_check") == "not_checked": w(f" not checked: {q.get('sex_check_reason')}") w(f" Contamination (VerifyBamID2 FREEMIX): {freemix_text(q)}") @@ -982,6 +1057,7 @@ def head(key, title=None): w(f" ** The same person as: {q['same_person_as']} (a duplicate or a sample swap) **") for line in karyotype_lines(S): w(f" {line}") + w(f" {COVERAGE_NOTE}") w("") if head("callability", "Genes This Genome Could Not Read Reliably"): @@ -1004,6 +1080,7 @@ def head(key, title=None): w(f" Total variants: {d['total']}") w(f" PASS variants: {d['pass']}") w(f" SNPs: {d['snps']} Indels: {d['indels']}") + w(f" {VARIANTS_NOTE}") w("") if head("clinvar", "ClinVar Pathogenic Screen"): @@ -1066,6 +1143,7 @@ def head(key, title=None): w(f" Report: {S['pharmcat']['source']} (PharmCAT {d.get('version') or 'version unknown'})") if d.get("html_report"): w(" HTML report beside it (open in a browser for full details)") + w(f" {PHARMCAT_NOTE}") w("") if head("cpic", "CPIC Drug-Gene Recommendations"): @@ -1081,6 +1159,7 @@ def head(key, title=None): for warn in d["warnings"]: w(f" WARNING: {warn}") w(f" Drugs per gene: {os.path.join(os.path.dirname(S['cpic']['source']), s['sample'] + '_cpic_recommendations.txt')}") + w(f" {CPIC_NOTE}") w("") if head("pypgx", "pypgx Pharmacogenomics"): @@ -1121,6 +1200,7 @@ def head(key, title=None): w(f" Step 36: {verdict}") if s["cyp2d6"].get("probable"): w(f" {s['cyp2d6']['probable']['label'].capitalize()}: {probable_text(s['cyp2d6']['probable'])}") + w(f" {PGX_CALLERS_NOTE}") w("") if head("paralogs", "Paralog Genes (Paraphase, PacBio HiFi)"): @@ -1200,18 +1280,25 @@ def head(key, title=None): w(f" {ANCESTRY_FINE_NOTE}") w("") + sv_note = [f" {SV_CALLERS_NOTE}"] # printed once, under the first structural caller with results if head("manta", "Structural Variants (Manta)"): d = S["manta"]["data"] w(f" Total SVs: {d['total']} PASS: {d['pass']}") + while sv_note: + w(sv_note.pop()) w("") if head("delly", "Structural Variants (Delly)"): d = S["delly"]["data"] w(f" Total SVs: {d['total']} PASS: {d['pass']}") + while sv_note: + w(sv_note.pop()) w("") if head("cnvpytor", "Copy Number Variants (CNVpytor)"): d = S["cnvpytor"]["data"] w(f" Total CNVs: {d['total']} ({d['deletions']} deletions, {d['duplications']} duplications)") w(f" Significant (e-val < 0.01): {d['significant']}") + while sv_note: + w(sv_note.pop()) filt = cnvpytor_filtered_text(d) if filt: w(f" Significant after filters: {filt}") @@ -1347,11 +1434,13 @@ def head(key, title=None): if head("lpa", "Lp(a): LPA KIV-2 Repeat Count and Risk Variants"): for line in lpa_lines(S["lpa"]["data"]): w(f" {line}") + w(f" {LPA_NOTE}") w("") if head("vntyper", "MUC1 VNTR Frameshifts (ADTKD-MUC1, VNtyper)"): for line in vntyper_lines(S["vntyper"]["data"]): w(f" {line}") + w(f" {VNTYPER_NOTE}") w("") if head("chip", "Clonal Haematopoiesis (CHIP, step 49)"): @@ -1414,6 +1503,7 @@ def head(key, title=None): w(" Classification table has no CLASSIFICATION column.") for line in cpsr_lines(d, s["databases"]): w(f" {line}") + w(f" {CPSR_NOTE}") w("") if head("clinical", "Clinical Variant Filter"): @@ -2553,6 +2643,7 @@ def a(key, lines): qc.append(stat("Identity and contamination (step 33)", badge("Not run", "gray"))) kl = karyotype_lines(S) qc += [stat("Sex chromosomes", badge(x.split(": ", 1)[1], "red")) for x in kl[:-1]] + [small_note(x) for x in kl[-1:]] + qc.append(small_note(COVERAGE_NOTE)) qc_note = [f'
    {E(sec["title"] + ": " + stale_note(sec))}
    ' for sec in (cov, sexc, sqc, rdc) if stale_note(sec)] a("coverage", ['
    ', "

    Quality Control

    "] + qc_note + qc + ["
    "]) @@ -2576,7 +2667,8 @@ def a(key, lines): v = S["variants"]["data"] a("variants", card(S["variants"], "Variant Calling", [stat("Total variants", v.get("total")), stat("PASS variants", v.get("pass")), - stat("SNPs", v.get("snps")), stat("Indels", v.get("indels"))])) + stat("SNPs", v.get("snps")), stat("Indels", v.get("indels")), + small_note(VARIANTS_NOTE)])) c = S["clinvar"]["data"] n = c.get("count", 0) @@ -2614,12 +2706,14 @@ def a(key, lines): pg = S["pharmcat"]["data"] cp = S["cpic"]["data"] - pgx_body = [stat("PharmCAT report", badge("Complete", "green") if S["pharmcat"]["state"] in ("ok", "stale") + # Never green: a report that was written is not a finding (the frame's own Complete badge is neutral too). + pgx_body = [stat("PharmCAT report", badge("Complete", "state") if S["pharmcat"]["state"] in ("ok", "stale") else badge("Not run", "gray"))] if pg.get("version"): pgx_body.append(stat("PharmCAT version", E(pg["version"]))) if S["pypgx"]["state"] in ("ok", "stale") and "comparison" in S["pypgx"]["data"]: pgx_body.append(stat("PharmCAT vs pypgx conflicts", S["pypgx"]["data"]["comparison"]["conflicts"])) + pgx_body.append(small_note(PHARMCAT_NOTE)) a("pharmcat", card(S["pharmcat"], "Pharmacogenomics", pgx_body)) if S["pypgx"]["state"] != "missing": pd = S["pypgx"]["data"] @@ -2641,6 +2735,7 @@ def a(key, lines): cpic_body.append(stat("PharmCAT report", badge("could not be parsed", "red"))) cpic_body.append(stat("Tip", 'Recommendations per gene: ' f'cpic/{E(s["sample"])}_cpic_recommendations.txt')) + cpic_body.append(small_note(CPIC_NOTE)) a("cpic", card(S["cpic"], "CPIC Drug Recommendations", cpic_body)) calls = s["cyp2d6"]["calls"] @@ -2650,13 +2745,14 @@ def a(key, lines): if agree is False else badge("fewer than two usable calls", "gray"))) cons = s["cyp2d6"].get("consensus") if cons: - cy_body.append(stat("Step 36", badge(cons.get("result") or "?", "green" if cons.get("passed_to_pharmcat") + cy_body.append(stat("Step 36", badge(cons.get("result") or "?", "state" if cons.get("passed_to_pharmcat") else "yellow"))) cy_body.append(small_note(("Passed to PharmCAT: " if cons.get("passed_to_pharmcat") else "Not passed to PharmCAT: ") + (cons.get("reason") or "no reason given"))) prob = s["cyp2d6"].get("probable") if prob: cy_body.append(stat(prob["label"].capitalize(), E(probable_text(prob)))) + cy_body.append(small_note(PGX_CALLERS_NOTE)) a("cyrius", ["
    ", "

    CYP2D6 Across Callers

    "] + [f'
    {E(S[k]["title"] + ": " + stale_note(S[k]))}
    ' for k in ("cpic", "pypgx", "cyrius") if S[k]["state"] in ("stale", "failed")] @@ -2696,6 +2792,7 @@ def a(key, lines): if cnvpytor_filtered_text(cn): sv_body += [stat("CNVpytor significant after filters", E(cnvpytor_filtered_text(cn))), small_note(CNVPYTOR_NOTE)] sv_body.append(stat("Consensus SVs (2+ callers)", sv.get("consensus", "N/A"))) + sv_body.append(small_note(SV_CALLERS_NOTE)) notes = [f'
    {E(S[k]["title"] + ": " + stale_note(S[k]))}
    ' for k in ("manta", "delly", "cnvpytor", "sv_consensus") if stale_note(S[k])] a("manta", ['
    ', "

    Structural Variants

    "] + notes + sv_body + ["
    "]) @@ -2724,10 +2821,11 @@ def a(key, lines): a("annotsv", card(S["annotsv"], "Structural Variants, Class 4 and 5 (AnnotSV)", body, full=True)) cs = S["cpsr"]["data"] - cpsr_body = [stat("CPSR report", badge("Complete", "green") if cs.get("html_report") else badge("No HTML", "gray"))] + cpsr_body = [stat("CPSR report", badge("Complete", "state") if cs.get("html_report") else badge("No HTML", "gray"))] for k, val in (cs.get("classification") or {}).items(): cpsr_body.append(stat(k, val)) cpsr_body += [small_note(line.strip()) for line in cpsr_lines(cs, s["databases"])] + cpsr_body.append(small_note(CPSR_NOTE)) a("cpsr", card(S["cpsr"], "Cancer Predisposition", cpsr_body)) eh = S["expansions"]["data"] @@ -2834,13 +2932,13 @@ def a(key, lines): lp = S["lpa"]["data"] a("lpa", card(S["lpa"], "Lp(a): LPA KIV-2 Repeat Count", ([stat("Genetic sign of high Lp(a)", badge("Yes", "yellow"))] if lp.get("flag") == "high" else []) - + [f"

    {E(line)}

    " for line in lpa_lines(lp)])) + + [f"

    {E(line)}

    " for line in lpa_lines(lp)] + [small_note(LPA_NOTE)])) if S.get("vntyper", MISSING)["state"] != "missing": vn = S["vntyper"]["data"] a("vntyper", card(S["vntyper"], "MUC1 VNTR Frameshifts (VNtyper)", ([stat("Result", badge(vn.get("status", "."), {"positive": "red", "possible": "yellow", "negative": "green"}.get(vn.get("status"), "gray")))]) - + [f"

    {E(line)}

    " for line in vntyper_lines(vn)])) + + [f"

    {E(line)}

    " for line in vntyper_lines(vn)] + [small_note(VNTYPER_NOTE)])) if S.get("viral", MISSING)["state"] != "missing": a("viral", card(S["viral"], "Viral DNA in the Unmapped Reads", viral_body(S["viral"]["data"]), full=True)) if S.get("chip", MISSING)["state"] != "missing": diff --git a/docs/images/demo-live-site.png b/docs/images/demo-live-site.png index 9478659e..db28216f 100644 Binary files a/docs/images/demo-live-site.png and b/docs/images/demo-live-site.png differ diff --git a/docs/live-site.md b/docs/live-site.md index 26018b78..5434e241 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -1,22 +1,22 @@ # Watch a Run Live -A full run takes a day or more. The live site shows it while it goes, in a browser on any device that can reach the machine: which step is running, which finished, which failed or was skipped, and one line on what each finished step found. The page repaints as messages arrive; nobody has to reload it. +A full run takes a day or more. The live site shows each person's results as their steps finish, in a browser on any device that can reach the machine: the report's chapters as tabs, one card per section with its numbers, its rows and its caveat, and what is worth a look first. A Progress tab keeps the step map. A home page lists everyone by family, and each family has a page of its own. The pages repaint as messages arrive; nobody has to reload them. `bin/owngenome_live.py` is the server. It uses Python's standard library only, so the `python3` on the host is enough, and it has its own password, access log and limits, so it needs no web server in front of it. ## Try the demo -https://geiserx.github.io/OwnGenome/demo/ is the live site on DEMO-001, the invented sample of the docs pictures. It replays a made-up run from the first task to the database re-check after it: press Play, or drag the slider. Every number, result and family member in it is made up. +https://geiserx.github.io/OwnGenome/demo/ is the live site on DEMO-001, the invented sample of the docs pictures. It replays a made-up run from the first task to the database re-check after it: press Play, or drag the slider, and watch the cards fill in as their steps end. Every number, result and family member in it is made up. -![The live page of DEMO-001 at the end of the demo run: the run marked finished above a Play button and a time slider, the step map with every step done and a dot on the steps a newer ClinVar would change, and the Found so far list with one line per finished section, starting with the read and variant calling steps and the ClinVar screen's hits with its caveat](images/demo-live-site.png){ loading=lazy } +![The person page of DEMO-001 at the end of the demo run: the name with a progress strip of 54 finished steps, a tab column (Look first, Trust, Doctor, Child, Medicines, Ancestors, Traits, Other, Leaves out, Progress) with Doctor open, and the ClinVar card marked worth a look and newer ClinVar available, with its counts and the chromosome chart of its hits](images/demo-live-site.png){ loading=lazy } -The page and its 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 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 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. What differs from your own site: - It is a static folder published with these docs, so it has no password and no access log. Nothing in it is private. - The run-health card measures an invented machine (64 GiB of RAM, a 2 TiB disk 55% used), not the one that built the page. -- The CPIC recommendations and the gene callability table have no file in the demo sample, so the demo shows those two sections as missing. +- The CPIC recommendations have no file in the demo sample, so the demo shows that card as not run. ## Start it @@ -43,14 +43,29 @@ weblog { } ``` -## What the page shows +## What the pages show + +**Home** (`/`). Everyone, grouped by family, then the runs in no family. A card shows the pedigree symbol with a ring for the run's progress, the person's place in the family, the steps done and how many cards the report flags. Runs still going come first in each family. A family member with no run on this server is listed, dashed. Without scripts the page is a plain list of runs. + +**A person** (`/runs//`). The name, the declared and measured sex, the families they are in, and a strip with one cell per planned step. Below, one tab per chapter of the report, each named by its question: + +- **Look first** opens by default once a step has finished: the cards the report itself flags, in chapter order, each with its headline. A card not listed is for information; its caveats still apply. +- **Trust, Doctor, Child, Medicines, Ancestors, Traits, Other** follow the report's seven chapters. One card per section, with the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts. Under each card sits its caveat: the first sentence, then the rest behind "Full caveat". A card is marked "worth a look" (▲) only where the report or the step headline raises a flag, and "for information" (○) otherwise; no card says a section is fine. A card whose step has not ended says so; one whose step failed or is older than its input says that. A newer database release shows on the cards it would change. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. +- **Leaves out** lists what the run does not assess. +- **Progress** is the step map, a step's detail, Found so far, the run-health card and the database re-check, as before. + +Each card links to its section of the report and to the files it names. The header search finds a gene or rsID across the results and opens the card that names it. `#q/` opens a tab, `#s/
    ` opens a card (the report's own anchors), `#progress` the step map. - **The step map.** One box per step the run planned, drawn from `assets/steps.tsv`: a step sits right of the steps whose output it reads. The colour is the step's state from `bin/run_state.py` ([Where a Run Stands](run-state.md)): waiting, running, done, failed, skipped, not finished. Skipped steps stay on the map, greyed. - **A step's detail.** Click a box: its state and reason, start and end, the usual hours, each process with its tasks waiting, running, done, from the cache or failed, and the headline of its sections with a link into the report. -- **Found so far.** One line per finished section, worded by the same code that writes the report, caveat included. A line marked for attention means the report has something to read there; no line ever says a section is fine. The ClinVar line, with no hit, is information, never an all-clear. +- **Found so far.** One line per finished section, worded by the same code that writes the report, caveat included. No line ever says a section is fine. The ClinVar line, with no hit, is information, never an all-clear. - **The report.** Once written, `report.html` opens the run's `_report.html`, and the files it links to beside it. - **The genome viewer.** `/runs//viewer/` shows the run's reads and variants in igv.js, as [`scripts/owngenome-view.sh`](viewer.md) does on one computer. The report's Viewer links and the charts' "See the reads" links open it at their position. +**A family** (`/runs//`). The pedigree, drawn from `members.tsv` or `pedigree.tsv`, where each sequenced person with a run here opens their page and shows how many cards their report flags. Then the members, the analyses step 38 planned, each couple's carrier screen in full (genes where both carry a finding, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report, who is related how (step 38's relationship check beside the shared segments), and each child's chromosome map. + +On a phone the tab column becomes a row that scrolls sideways and each table row becomes a short list of label and value. The pages follow the browser's dark or light setting. + ## The genome viewer `bin/owngenome_live_viewer.py` serves it, behind the same password as every page. `owngenome-live.sh` passes it the reference (`REF_FASTA`, as in every step) and igv.js, which the first start downloads into `GENOME_DIR/tools/` and checks against `IGV_JS_SHA256` in `versions.env`. Without the reference's `.fai` or igv.js the site starts without the viewer and says why; `OWNGENOME_LIVE_VIEWER=off` leaves it out on purpose. Running `owngenome_live.py` yourself (in Docker, say), pass `--viewer-fasta` and `--viewer-igv-js`. @@ -59,11 +74,13 @@ Per run it serves the folder's BAM or CRAM, its VCF and its SV VCF, each with it The reads cross the network to the browser that logged in: only the byte ranges igv.js asks for, but they are the sample's reads. Off loopback, use TLS (`--tls-cert` and `--tls-key`, or a proxy that ends TLS); without it the password and the reads cross the network in clear. Opening the viewer writes one access-log row; the byte ranges igv.js reads write none. -`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. +`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. + +The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. ## Privacy and what is stored -- The site serves only its own pages, the run's state, the report, the files the report links to by a relative link, and the genome viewer's files of the run (see above). Any other file is 404, and so is a link that leaves the run folder or a symlink out of `GENOME_DIR`. It never lists a folder: a link to a folder that holds one HTML file (the report's `cpsr/`, with CPSR's own report) opens that file, and any other folder link is 404. +- The site serves only its own pages, the run's state, the results the report prints (`results.json`, never `summary.json` itself), the report, the files the report links to by a relative link, and the genome viewer's files of the run (see above). Any other file is 404, and so is a link that leaves the run folder or a symlink out of `GENOME_DIR`. It never lists a folder: a link to a folder that holds one HTML file (the report's `cpsr/`, with CPSR's own report) opens that file, and any other folder link is 404. - Each message is cut to what a progress view needs before it is stored: the task, its process and status, times, CPU and memory figures. Nextflow's parameters, command line and task scripts are dropped. Three paths stay on the server for later views (the output folder, the CPU and memory caps, the work directory); none of them reaches a page. - Messages go to `/runs//events.jsonl` (state dir: `GENOME_DIR/.owngenome-live`, or `OWNGENOME_LIVE_STATE_DIR`). The site writes nowhere else, so `GENOME_DIR` can be mounted read-only. - The access log, `/access.log`, has one tab-separated row per page or file read and per login (the genome viewer's files excepted: one row per page open, see above): time, client, the first 8 characters of a hash of the session, action, run, file, status. It never holds a query string, a request body, a result or the password. It moves to `access.log.1` past `--access-log-max-mb` (10). @@ -148,11 +165,11 @@ WantedBy=multi-user.target ## Plugins -The core files (`bin/owngenome_live.py`, `assets/live/index.html`, `live.js`, `live.css`, `login.html`) are meant to stay small. A new view adds two files instead of editing them: +The core files (`bin/owngenome_live.py`, `assets/live/index.html`, `home.html`, `family.html`, `core.js`, `live.js`, `person.js`, `home.js`, `family-page.js`, `live.css`, `login.html`) are meant to stay small. A new view adds two files instead of editing them: - `bin/owngenome_live_.py` with `register(app)`. At start the server imports every such file, sorted, and calls it. `app.route(method, pattern, fn, auth=True)` adds a page or an endpoint (behind the login unless `auth=False`), `app.run_file(glob)` lets the browser fetch more files of a run folder, `app.report_filter(fn)` rewrites the served report, `app.state_hook(name, fn)` adds `plugins[name]` to `state.json`, `app.run_kind(fn)` lists more runs, `app.add_argument(...)` adds a flag, `app.log(...)` writes an access-log row, and `app.config` holds the parsed flags. -- `assets/live/plugins/.js`. The run page loads every script in that folder after `live.js`; it calls `window.PGP.register({name, onState, onEvent, panel})`. `onState(state)` gets each new `state.json`, `onEvent(message)` each message from the stream, and `panel(element)` gets a card of its own in the side column. +- `assets/live/plugins/.js`. The run page loads every script in that folder after `live.js`; it calls `window.PGP.register({name, onState, onEvent, panel})`. `onState(state)` gets each new `state.json`, `onEvent(message)` each message from the stream, and `panel(element)` gets a card of its own in the Progress tab, or in the element whose id `slot` names (the search box sits in the header this way). A family's page loads the same scripts without `live.js`, so a plugin returns early when `window.PGP` is missing. The page has a replay mode for a demo with no server: `` reads `frames.json` (`{"interval_ms": 1000, "frames": [{"t": ..., "state": }]}`) and plays it with a play button and a slider, through the same drawing code. -`tests/test_owngenome_live.py` checks the server, the login, the stored messages, the plugin interface and the page in a headless Chrome; `tests/test_live_post.sh` and `tests/fake-docker/run-all-live.sh` check what `run-all.sh` posts. +`tests/test_owngenome_live.py` checks the server, the login, the stored messages, the plugin interface and the page in a headless Chrome; `tests/test_live_results.py` checks `results.json` (every card's caveat word for word, no wording that reads as an all-clear, `results.json` behind the login); `tests/test_live_demo.py` builds the demo and checks every page, phone width included; `tests/test_live_post.sh` and `tests/fake-docker/run-all-live.sh` check what `run-all.sh` posts. diff --git a/docs/output-contract.md b/docs/output-contract.md index 0d49b17c..bfab4e87 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -10,7 +10,7 @@ The full list is one file, [`assets/output_contract.tsv`](https://github.com/Gei - **`--tools` names:** every name `main.nf` accepts (`pharmcat`, `clinvar`, `vcfanno`, ...). - **`run-all.sh`:** its two arguments (` `) and every switch in its `SWITCHES` list (`TOOLS`, `THREADS`, `SKIP_TRIM`, `INTERVALS`, `REF_FASTA`, ...). - **Result files:** every file the Nextflow pipeline publishes, which is also what `run-all.sh` writes, at its path under `GENOME_DIR//` (or `--outdir`). For each one the contract gives its step, its format and the fields a reader can count on: the columns of a table, the INFO and FORMAT fields of a VCF header, the top-level keys of a JSON file (`summary.json`'s keys are the ones its schema, `tests/schema/summary.schema.json`, requires). -- **Single-step scripts:** the files listed with leg `bash` or `both`. Some single steps write to other folders than the pipeline does (step 7 writes PharmCAT's reports to `vcf/`, the pipeline to `pharmcat/`; step 16b writes to `mosdepth/`, the pipeline to `coverage/`). Both names are kept. A single-step output that is not in the file is not part of the promise yet. +- **Single-step scripts:** the files listed with leg `bash` or `both`. A row is `both` when the step's script (`scripts/-*.sh`) writes the same path under `GENOME_DIR`. Some single steps write to other folders than the pipeline does (step 7 writes PharmCAT's reports to `vcf/`, the pipeline to `pharmcat/`; step 16b writes to `mosdepth/`, the pipeline to `coverage/`). Both names are kept. A single-step output that is not in the file is not part of the promise yet. ## What counts as a break @@ -40,7 +40,7 @@ The engines that run by default, and every step that existed before 1.0, are `st Two checks read the contract, both with [`scripts/ci/check-output-contract.py`](https://github.com/GeiserX/OwnGenome/blob/main/scripts/ci/check-output-contract.py): -- **On every pull request, with no run** (`tests/test_output_contract.py`, in the `unit-tests` job of the Guard workflow): every param of `nextflow_schema.json`, every `--tools` name of `main.nf` and every switch of `run-all.sh` has a row, and every row still names one. Every file row of a Nextflow process still matches a file that process writes and publishes, and every file a module publishes has a row. A removed or renamed stable item fails as `BREAKING`. A new one with no row fails too, with the message to add it, so the contract is always the full list. The test also plants each kind of break on a copy of the code (a renamed output, a removed param, a removed switch, a new tool and a new output with no row) and requires each to fail. +- **On every pull request, with no run** (`tests/test_output_contract.py`, in the `unit-tests` job of the Guard workflow): every param of `nextflow_schema.json`, every `--tools` name of `main.nf` and every switch of `run-all.sh` has a row, and every row still names one. Every file row of a Nextflow process still matches a file that process writes and publishes, in the folder of a `publishDir` that keeps it (a `saveAs` that returns `null` for the file does not count), and every file a module publishes has a row. A published row with leg `nextflow` fails when its step's script writes the same path too, since its leg is then `both`; the check reads the script with its shell variables filled in, so a path it cannot follow is not flagged, and a `both` row it does not find is not refused. Every default in `nextflow_schema.json` must equal the value in `nextflow.config`. A removed or renamed stable item fails as `BREAKING`. A new one with no row fails too, with the message to add it, so the contract is always the full list. The test also plants each kind of break on a copy of the code (a renamed output, a removed param, a removed switch, a new tool and a new output with no row, a schema default that differs from `nextflow.config`, a row in the folder a `saveAs` drops the file from, and a `nextflow` row a step script also writes) and requires each to fail. - **On the HG002 end-to-end run** (`tests/e2e/output-contract-1-hg002.sh`): every stable file whose `e2e` column names a leg must be in the results that leg wrote, under its contracted name, with its required fields. The bash leg is the single-step scripts the base cases run; the Nextflow leg is case `60-nextflow`. Rows whose step the fixture run does not reach have `e2e` set to `-` and are held by the static check only. The two files `run-all.sh` writes itself, `run_manifest.tsv` and `logs/run_status.tsv`, are listed but no check holds them yet: neither the fixture run nor a module writes them. The same case copies the Nextflow results, drops the `clnsig` column of the ClinVar hits table and renames the ROH summary, and requires the check to fail on both. It also prints every file each leg wrote with its fields, which is what to read when the contract is written again. ## Changing the contract diff --git a/main.nf b/main.nf index 7160fb9d..fb57d54e 100644 --- a/main.nf +++ b/main.nf @@ -61,13 +61,12 @@ workflow { // ─── Validate inputs ──────────────────────────────────────────────── // Every parameter must be one nextflow_schema.json declares: Nextflow // takes any --name as a new parameter, so a misspelt option (--outdirr, - // --max-cpus) would be ignored with no word. --pedigree is read with - // containsKey and has no default; config_profile_* are the test - // profiles' labels. + // --max-cpus) would be ignored with no word. config_profile_* are the + // test profiles' labels. def schema_params = new groovy.json.JsonSlurper() .parseText(file("${projectDir}/nextflow_schema.json").text)['definitions'] .values().collectMany { group -> (group['properties'] ?: [:]).keySet() as List } - def known_params = schema_params + ['pedigree', 'config_profile_name', 'config_profile_description'] + def known_params = schema_params + ['config_profile_name', 'config_profile_description'] def unknown_params = params.keySet().findAll { k -> !known_params.contains(k) }.sort() if (unknown_params) { error "unknown parameter${unknown_params.size() > 1 ? 's' : ''} " + @@ -332,9 +331,8 @@ workflow { // leading family column: one family runs, the one --family_name names // (needed when the file holds more than one). Here only the family and // sample columns are read; FAMILY_PREPARE (bin/family_report.py prepare) - // checks the rest before the joint call starts. Read with containsKey, - // so a run without the option is not told about an undefined parameter. - def pedigree = params.containsKey('pedigree') ? params.pedigree : null + // checks the rest before the joint call starts. + def pedigree = params.pedigree // The family's folder under --outdir: --family_name, else 'family' def family_dir = params.family_name ?: 'family' def family_ids = [] diff --git a/nextflow.config b/nextflow.config index 176b8db8..5b95c3bf 100644 --- a/nextflow.config +++ b/nextflow.config @@ -121,6 +121,7 @@ params { viral_ref = null // viral: the viral genomes of step 47 (setup.sh --viral-data: reference/viral-/viral_genomes.fa) hpo = null // exomiser: the symptoms as HPO ids, comma separated (HP:0001250,HP:0001263); setting it runs exomiser (step 48) exomiser_data = null // exomiser: Exomiser's data folder (setup.sh --exomiser-data: reference/exomiser-) + pedigree = null // opt-in: a pedigree or families file (assets/pedigree.example.tsv, assets/families.example.tsv); runs the family workflow (steps 38, 39) family_name = null // --pedigree: the family to run of a families file (assets/families.example.tsv) that holds more than one; also its folder under --outdir (default family) exomiser_proband = null // exomiser: the sample the --hpo terms describe; needed when the samplesheet has more than one exomiser_assembly = 'hg38' // exomiser: the assembly of its data; hg19 only for Exomiser's cut-down test release (CI) diff --git a/nextflow_schema.json b/nextflow_schema.json index 6795fd58..91e62b6f 100644 --- a/nextflow_schema.json +++ b/nextflow_schema.json @@ -105,7 +105,7 @@ "tools": { "type": "string", "description": "Comma-separated list of tools to run; an unknown name stops the run. DB-heavy tools (vep, slivar, clinical_filter, cpsr, clinvar, expansion_hunter, sample_qc) and extras (stranger, ancestry (needs --ancestry_ref and prs), ancestry_fine (needs --ancestry_fine_panel), ancient (needs --ancient_panel; samples with a gVCF), pypgx, SV callers, cram_archive, cyrius, parascopy, paraphase (PacBio HiFi BAMs only), y_haplogroup (male samples), y_str (Y-STR repeat counts, male samples), consumer_export (samples with a gVCF), bloodtype (blood group antigens; RhD needs the BAM), lpa (the LPA KIV-2 repeat count; needs the BAM), vntyper (MUC1 VNTR frameshifts behind ADTKD-MUC1; needs the BAM), viral (viral DNA in the unmapped reads; needs --viral_ref and a BAM), exomiser (runs when --hpo is set), chip (clonal haematopoiesis from the BAM; needs --chip_gff), archaic (needs --archaic_data), mei (Alu, L1 and SVA insertions with MEGAnE; needs --megane_kmers and a BAM), mocha (mosaic chromosomal alterations; needs --mocha_data), locityper (a second opinion at HLA class II, KIR, CYP2D6 and FCGR; samples with a BAM; needs --locityper_data)) are opt-in. prs and vcfanno are skipped with a warning until --pgs_scoring or a score file is set. With pharmcat, PharmCAT also gets the HLA types of hla_typing and a CYP2D6 call pypgx and cyrius agree on (PGX_CONSENSUS).", - "default": "pharmcat,cpic,vcfanno,roh,prs,mito_haplogroup,telomere_hunter,mosdepth,mito_variants,html_report,multiqc", + "default": "pharmcat,cpic,vcfanno,roh,prs,mito_haplogroup,telomere_hunter,mosdepth,mito_variants,traits,html_report,multiqc", "fa_icon": "fas fa-toolbox" } } @@ -344,6 +344,12 @@ "description": "Exomiser's data folder, which `scripts/setup.sh --exomiser-data` installs as reference/exomiser- (its _hg38 and _phenotype folders). Required with --hpo.", "fa_icon": "fas fa-folder" }, + "pedigree": { + "type": "string", + "format": "file-path", + "description": "Opt-in: run the family workflow (steps 38 and 39) on the samples of the samplesheet this file names: a pedigree (assets/pedigree.example.tsv), or a families file with a leading family column (assets/families.example.tsv). A families file that holds more than one family needs --family_name, and only that family runs. The family that runs needs two or more of those samples, each with a gVCF, and --somalier_sites.", + "fa_icon": "fas fa-sitemap" + }, "family_name": { "type": "string", "pattern": "^[a-zA-Z0-9._][a-zA-Z0-9._-]*$", diff --git a/scripts/ci/check-output-contract.py b/scripts/ci/check-output-contract.py index e397cad5..d0dd192e 100755 --- a/scripts/ci/check-output-contract.py +++ b/scripts/ci/check-output-contract.py @@ -8,10 +8,14 @@ run: every param of nextflow_schema.json, every --tools name of main.nf (known_tools) and every run-all.sh switch (its SWITCHES list) has a row, and every row still names one; every file row of a process names a file - that process still writes and publishes; and every file a module - publishes has a row. A stable row that no longer matches is a break. A - new param, tool, switch or published file with no row fails too, so the - contract stays the full list. + that process still writes and publishes, in the folder of a publishDir + that keeps it; every file a module publishes has a row; a nextflow row + (not internal) whose step script writes the same path is refused (its + legs are both); + and every default of nextflow_schema.json equals nextflow.config's. A + stable row that no longer matches is a break. A new param, tool, switch + or published file with no row fails too, so the contract stays the full + list. --outputs DIR --leg bash|nextflow --sample S [--observe] The files a run wrote. DIR is the folder that holds / (GENOME_DIR @@ -109,8 +113,49 @@ def concrete(pattern, sample="SAMPLE"): return p.strip("/") +SAVE_AS = re.compile(r"^\s*(\w+)\s*->\s*\1\s*(?:\.(endsWith|startsWith)\(\s*'([^']*)'\s*\)|==\s*'([^']*)')" + r"\s*\?\s*([^?:]+?)\s*:\s*([^?:]+?)\s*$", re.S) + + +def closure_body(stmt): + """The body of a publishDir's saveAs closure, up to its own closing brace (an option + after it is not part of it), or None when the statement has no saveAs.""" + m = re.search(r"saveAs:\s*\{", stmt) + if not m: + return None + depth, i = 1, m.end() + while i < len(stmt): + depth += {"{": 1, "}": -1}.get(stmt[i], 0) + if depth == 0: + return stmt[m.end():i] + i += 1 + return stmt[m.end():] + + +def save_as(closure, name): + """What a publishDir's saveAs closure does to a task file name (an output glob): + 'keep' (published under its own name), 'drop' (returns null) or 'rename'. + A closure this reader does not follow counts as 'rename', which the static + check matches by basename only.""" + if closure is None: + return "keep" + m = SAVE_AS.match(closure) + if not m: + return "rename" + var, method, arg, eq, yes, no = m.groups() + if method == "endsWith": + cond = name.endswith(arg) + elif method == "startsWith": + cond = name.startswith(arg) + else: + cond = name == eq + expr = (yes if cond else no).strip() + return "drop" if expr == "null" else "keep" if expr == var else "rename" + + def module_processes(repo): - """{process: {'publish': [(dir, [patterns] or None, has_saveas)], 'outputs': [glob]}} of modules/local.""" + """{process: {'publish': [(dir, [patterns] or None, saveAs closure body or None)], 'outputs': [glob]}} + of modules/local.""" procs = {} for f in sorted(glob.glob(os.path.join(repo, "modules", "local", "*", "main.nf"))): src = open(f).read() @@ -134,7 +179,7 @@ def module_processes(repo): pat = re.search(r"pattern:\s*[\"']([^\"']+)[\"']", stmt) pubs.append((concrete(d.group(1)) if d else "", braces(pat.group(1)) if pat else None, - "saveAs" in stmt)) + closure_body(stmt))) out = re.search(r"^\s*output:\s*$(.*?)(?:^\s*(?:when|topic):|\Z)", head, re.M | re.S) outputs = [] if out: @@ -147,15 +192,19 @@ def module_processes(repo): def published(proc): - """(contracted-path glob, basename-only) pairs a process publishes; basename-only when a saveAs renames.""" + """(output glob, contracted-path glob, basename-only) of each file a process publishes. A publishDir + whose saveAs drops the file does not count; basename-only when its saveAs renames the file.""" items = [] - for d, pats, saveas in proc["publish"]: + for d, pats, closure in proc["publish"]: for g in proc["outputs"]: c = concrete(g) if pats is not None and not any(fnmatch.fnmatch(c.replace("*", "X"), p) or fnmatch.fnmatch(c, p) for p in pats): continue - items.append((g, (d + "/" + c) if d else c, saveas)) + action = save_as(closure, c) + if action == "drop": + continue + items.append((g, (d + "/" + c) if d else c, action == "rename")) return items @@ -165,6 +214,82 @@ def schema_params(repo): return {p for group in defs.values() for p in group.get("properties", {})} +def schema_defaults(repo): + s = json.load(open(os.path.join(repo, "nextflow_schema.json"))) + defs = s.get("definitions") or s.get("$defs") or {} + return {p: v["default"] for group in defs.values() for p, v in group.get("properties", {}).items() + if "default" in v} + + +def config_params(repo): + """{name: value} of nextflow.config's params block, for the plain literals (strings, numbers, + true, false, null); a value this reader cannot parse is left out.""" + src = open(os.path.join(repo, "nextflow.config")).read() + m = re.search(r"^params\s*\{(.*?)^\}", src, re.M | re.S) + out = {} + if not m: + return out + lit = {"true": True, "false": False, "null": None} + for name, val in re.findall(r"^\s*(\w+)\s*=\s*('[^']*'|\"[^\"]*\"|[-\w.]+)", m.group(1), re.M): + if val[0] in "'\"": + out[name] = val[1:-1] + elif val in lit: + out[name] = lit[val] + else: + try: + out[name] = int(val) + except ValueError: + try: + out[name] = float(val) + except ValueError: + pass + return out + + +VAR = re.compile(r"\$\{(\w+)\}|\$(\w+)") +ASSIGN = re.compile(r"^\s*(?:local\s+|export\s+|readonly\s+)?([A-Za-z_]\w*)=(\"[^\"\n]*\"|'[^'\n]*'|[^\s;#()]+)", re.M) + + +def bash_paths(src): + """The lines of a step script with its shell variables expanded: SAMPLE as {sample}, + GENOME_DIR as G, every other variable as each value it is assigned (a few at most).""" + assign = {} + for v, val in ASSIGN.findall(src): + assign.setdefault(v, []).append(val.strip("\"'")) + fixed = {"SAMPLE": "{sample}", "GENOME_DIR": "G", "1": "{sample}"} + + # depth counts every variable filled in on the line, not the nesting: a line with + # ${WORK}, ${P} and P's own chain of OUTDIR, S, GENOME_DIR and SAMPLE needs seven. + # The search stops at 64 expansions of a line, so a deep line stays cheap. + def expand(s, depth=0): + m = VAR.search(s) + if not m or depth > 24: + return [s] + name = m.group(1) or m.group(2) + vals = [fixed[name]] if name in fixed else assign.get(name) or ["\0"] + out = [] + for val in vals[:4]: + out.extend(expand(s[:m.start()] + val + s[m.end():], depth + 1)) + if len(out) >= 64: + break + return out[:64] + + return {e for line in src.split("\n") if not line.lstrip().startswith("#") for e in expand(line)} + + +def bash_writes(repo, step, name, cache): + """The step script that names this per-sample path (scripts/-*.sh), or None.""" + if not name.startswith("{sample}/") or "*" in name or step == "-": + return None + if step not in cache: + files = sorted(glob.glob(os.path.join(repo, "scripts", f"{step}-*.sh"))) + cache[step] = (files, "\n".join(bash_paths("".join(open(f).read() for f in files))) if files else "") + files, text = cache[step] + # the whole path: a longer name that starts with it (x.tsv.gz for x.tsv) is not it + hit = files and re.search(re.escape("G/" + name) + r"(?![\w.-])", text) + return os.path.relpath(files[0], repo) if hit else None + + def known_tools(repo): src = open(os.path.join(repo, "main.nf")).read() m = re.search(r"def known_tools = \[(.*?)\]", src, re.S) @@ -193,6 +318,14 @@ def check_static(repo, rows): for name in sorted(have[kind] - set(listed)): problems.append(f"{kind} {name} of {where[kind]} has no row in the contract: add one") + defaults, config = schema_defaults(repo), config_params(repo) + if not config: + problems.append("found no params in nextflow.config: the reader no longer matches the code") + for name, val in sorted(defaults.items()): + if name in config and config[name] != val: + problems.append(f"param {name}: nextflow_schema.json's default {val!r} is not nextflow.config's " + f"{config[name]!r}") + procs = module_processes(repo) if not procs: problems.append("found no processes under modules/local") @@ -214,6 +347,13 @@ def check_static(repo, rows): what = "BREAKING: the stable file" if r["stability"] == "stable" else "the file" problems.append(f"{what} {r['name']} (contract line {r['line']}) is not what {r['process']} " f"publishes ({p['file']}): renamed, moved or no longer written") + cache = {} + for r in rows: + if r["kind"] == "file" and r["legs"] == "nextflow" and r["stability"] != "internal": + script = bash_writes(repo, r["step"], r["name"], cache) + if script: + problems.append(f"{r['name']} (contract line {r['line']}) has legs nextflow, but {script} " + "writes it under the same name too: its legs are both") for name, p in sorted(procs.items()): rows_p = [concrete(r["name"]) for r in file_rows if r["process"] == name] if not p["publish"]: diff --git a/scripts/ci/e2e-case-paths.tsv b/scripts/ci/e2e-case-paths.tsv index c302ec67..a1c3636b 100644 --- a/scripts/ci/e2e-case-paths.tsv +++ b/scripts/ci/e2e-case-paths.tsv @@ -100,7 +100,7 @@ hba-short-read-accuracy-1-nf-report assets/acmg_sf.tsv assets/callability_genes. hla-second-caller-1-bash assets/pgx_genes.tsv assets/stub/db/hla.dat assets/stub/db/kir.dat bin/cn_loci.tsv bin/copy_number.py bin/cyp2d6_depth_check.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/pgx_outside_calls.py bin/render_report.py scripts/08-hla-typing.sh scripts/24-html-report.sh scripts/32-pypgx.sh scripts/36-pgx-consensus.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh hla-second-caller-2-nextflow assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/mito_confirmed.tsv assets/pgx_genes.tsv assets/stub/db/hla.dat assets/stub/db/kir.dat assets/stub/sample.vcf.gz bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/cyp2d6_depth_check.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/pgx_mito_merge.py bin/pgx_outside_calls.py bin/pgx_refblock_fill.py bin/render_report.py bin/results_index.py modules/local/hla_typing/* modules/local/html_report/* modules/local/pgx_consensus/* modules/local/pharmcat/* modules/local/t1k_build/* modules/local/vcf_precheck/* scripts/07-pharmacogenomics.sh scripts/08-hla-typing.sh scripts/21-cyrius.sh scripts/24-html-report.sh scripts/32-pypgx.sh scripts/36-pgx-consensus.sh scripts/ci/fake-docker/lib.sh scripts/cyrius-constraints.txt scripts/setup.sh scripts/validate-setup.sh ibd-segments-1 assets/families.example.tsv assets/par_grch38.bed assets/pedigree.example.tsv assets/stub/pedigree.tsv bin/family_report.py bin/family_segments.py bin/ibd_report.py scripts/38-family.sh scripts/ci/fake-docker/lib.sh scripts/setup.sh -live-server-1-weblog assets/live/index.html assets/live/live.css assets/live/login.html assets/viewer/index.html bin/owngenome_live.py bin/render_report.py bin/run_state.py modules/local/roh/* modules/local/vcf_precheck/* scripts/11-roh-analysis.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh +live-server-1-weblog assets/live/home.html assets/live/index.html assets/live/live.css assets/live/login.html assets/viewer/index.html bin/live_results.py bin/owngenome_live.py bin/owngenome_live_results.py bin/render_report.py bin/run_state.py modules/local/roh/* modules/local/vcf_precheck/* scripts/11-roh-analysis.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh lpa-1 assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/mito_confirmed.tsv assets/par_grch38.bed assets/stub/sample.vcf.gz bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/render_report.py bin/results_index.py modules/local/deepvariant/* modules/local/html_report/* modules/local/lpa/* modules/local/vcf_precheck/* scripts/24-html-report.sh scripts/46-lpa.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh mei-1 assets/mei_sites.tsv assets/par_grch38.bed assets/sv_rule_regions.tsv bin/annotsv_postfilter.py bin/cnvpytor_to_vcf.py bin/collect_summary.py bin/mei_report.py bin/sv_svlen_fix.py modules/local/align_minimap2/* modules/local/annotsv/* modules/local/deepvariant/* modules/local/fastp/* modules/local/mei/* modules/local/vcf_precheck/* scripts/01b-fastp-qc.sh scripts/05-annotsv.sh scripts/15-duphold.sh scripts/18-cnvpytor.sh scripts/19-delly.sh scripts/50-mei.sh scripts/ci/check-images.sh scripts/ci/fake-docker/lib.sh scripts/setup.sh scripts/validate-setup.sh tests/fixtures/annotsv/class45.sh mito-extended-1-deletions assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/construct_variants.tsv assets/deep_intronic_sites.tsv assets/mito_confirmed.tsv assets/stub/db/clinvar.vcf.gz assets/stub/db/clinvar.vcf.gz.tbi assets/stub/sample.bam assets/stub/sample.bam.bai assets/stub/sample.vcf.gz bin/cdna_scan.py bin/clinvar_hits.awk bin/clinvar_join_adjacent.awk bin/clip_check.py bin/collect_summary.py bin/render_report.py bin/results_index.py modules/local/clinvar_screen/* modules/local/html_report/* modules/local/mito_variants/* modules/local/vcf_precheck/* scripts/06-clinvar-screen.sh scripts/16b-mosdepth.sh scripts/20-mtoolbox.sh scripts/24-html-report.sh scripts/ci/build-mito-confirmed.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh diff --git a/tests/demo/build_live_demo.py b/tests/demo/build_live_demo.py index 03124bcd..1d62b912 100644 --- a/tests/demo/build_live_demo.py +++ b/tests/demo/build_live_demo.py @@ -5,14 +5,19 @@ writes DIR, which the docs workflow publishes at /demo/ of the docs site: - index.html the list of runs, as the live site's / shows it - assets/ assets/live/ as it is: live.js, live.css, plugins/*.js - runs/DEMO-001/index.html the live page (assets/live/index.html) in replay mode + index.html the home page (assets/live/home.html), as the live site's / shows it + runs.json what the home page draws, as /runs.json gives it + assets/ assets/live/ as it is: core.js, live.js, person.js, live.css, plugins/*.js + runs/DEMO-001/index.html the person page (assets/live/index.html) in replay mode runs/DEMO-001/frames.json the page's state.json at each moment of the run + 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/... the files the plugins point at (the charts, the search index, the re-check), the report as report.html and the files it links to - runs/DEMO-FAMILY/ the family page of DEMO-001 and its invented partner + runs/DEMO-FAMILY/ the family page (assets/live/family.html) of DEMO-001 and its + invented partner, with family.json as the family plugin serves it family/DEMO-FAMILY/ the pedigree and the files the family page opens No state is written by hand. The frames are bin/owngenome_live.py's own @@ -63,7 +68,9 @@ REPO = os.path.dirname(os.path.dirname(HERE)) BIN = os.path.join(REPO, "bin") sys.path.insert(0, BIN) +import live_results # noqa: E402 import owngenome_live # noqa: E402 +import owngenome_live_family # noqa: E402 import owngenome_recheck # noqa: E402 import pedigree_svg # noqa: E402 import run_headroom # noqa: E402 @@ -217,9 +224,16 @@ def snap(t): fam = live.state(live.run(FAMILY)) fam["generated_utc"] = checked fam["plugins"] = relink(fam["plugins"]) + index = live.runs_index() + run, d = live.run(SAMPLE), os.path.join(root, SAMPLE) + # The demo's run folder has no run_status.tsv (OTHER_RUN), so the server knows it from its + # messages alone; the files it may serve are those of the folder all the same. + results = live_results.build(SAMPLE, live.summary(SAMPLE, d, live.base_state(run)), + owngenome_live.Live.run_files(None, {"dir": d, "name": SAMPLE})) + results["generated_utc"] = checked finally: time.time, run_headroom._statvfs, run_headroom.physical_memory = real - return frames, {"t": checked, "state": fam}, app + return frames, {"t": checked, "state": fam, "index": index, "results": results}, app # --- the static folder ---------------------------------------------------------------------- @@ -229,16 +243,47 @@ def with_csp(page): return page.replace('', '\n' + meta, 1) -def run_page(name, plugin_js): - with open(os.path.join(ASSETS, "index.html")) as f: +def run_page(name, plugin_js, template="index.html"): + with open(os.path.join(ASSETS, template)) as f: page = f.read() tags = "\n".join(f'' for f in plugin_js) page = (page.replace("{{assets}}", "../../assets").replace("{{run}}", html.escape(name)) - .replace("{{mode}}", "replay").replace("{{plugins}}", tags) - .replace('All runs', 'All runs')) + .replace("{{mode}}", "replay").replace("{{plugins}}", tags).replace('href="/"', 'href="../../"')) return with_csp(page) +def home_page(index): + """assets/live/home.html as the server fills it, with a line saying the people are invented and no + log-out button (a static host has no session).""" + with open(os.path.join(ASSETS, "home.html")) as f: + page = f.read() + items = "\n".join(f'
  • {html.escape(r["name"])}
  • ' for r in index["runs"]) + page = (page.replace("{{assets}}", "assets").replace("{{data}}", "runs.json").replace("{{runs}}", items) + .replace('
    ', + '

    A demo: the people, the run and the results are invented.

    ')) + return with_csp(page) + + +def unroot(v): + """/runs.json's absolute links as links relative to the site's root.""" + if isinstance(v, dict): + return {k: unroot(x) for k, x in v.items()} + if isinstance(v, list): + return [unroot(x) for x in v] + if isinstance(v, str) and re.match(r"^/(?:runs|family)/", v): + return v[1:] + return v + + +def family_json(root, index): + """family.json as bin/owngenome_live_family.py serves it, its links relative to runs/DEMO-FAMILY/.""" + out = owngenome_live_family.family_page(FAMILY, os.path.join(root, FAMILY)) + by = {r["name"]: r for r in index["runs"]} + out["runs"] = {p["sample"]: {k: by[p["sample"]][k] for k in ("url", "state", "planned", "done", "attention", "results")} + for p in out["state"].get("members") or [] if p["sample"] in by} + return relink(out) + + def plain_page(title, body, css): return with_csp('\n' '' @@ -255,23 +300,22 @@ def write_json(path, obj): def write_site(out, root, frames, fam): if os.path.exists(out): shutil.rmtree(out) - shutil.copytree(ASSETS, os.path.join(out, "assets"), ignore=shutil.ignore_patterns("index.html", "login.html")) + shutil.copytree(ASSETS, os.path.join(out, "assets"), + ignore=shutil.ignore_patterns("index.html", "login.html", "home.html", "family.html")) plugin_dir = os.path.join(ASSETS, "plugins") plugin_js = sorted(f for f in os.listdir(plugin_dir) if owngenome_live.ASSET_RE.match(f) and f.endswith(".js")) \ if os.path.isdir(plugin_dir) else [] - runs = [(SAMPLE, "sample"), (FAMILY, "family")] - items = "\n".join(f'
  • {n} {k}
  • ' for n, k in runs) with open(os.path.join(out, "index.html"), "w") as f: - f.write(plain_page("Runs", '

    A demo of the live site on DEMO-001, an invented sample: ' - 'the run, the results and the family are made up. Nothing here is a real person.

    ' - f'
      {items}
    ', "assets/live.css")) + f.write(home_page(fam["index"])) + write_json(os.path.join(out, "runs.json"), unroot(fam["index"])) sd, d = os.path.join(out, "runs", SAMPLE), os.path.join(root, SAMPLE) os.makedirs(sd) with open(os.path.join(sd, "index.html"), "w") as f: f.write(run_page(SAMPLE, plugin_js)) write_json(os.path.join(sd, "frames.json"), {"interval_ms": INTERVAL_MS, "autoplay": True, "frames": frames}) + write_json(os.path.join(sd, "results.json"), fam["results"]) files = {f"{SAMPLE}_report.html": "report.html"} # The files the report links to, as the live server lists them (Live.run_files reads no state). files.update({rel: rel for rel in owngenome_live.Live.run_files(None, {"dir": d, "name": SAMPLE})}) @@ -293,8 +337,9 @@ def write_site(out, root, frames, fam): fd = os.path.join(out, "runs", FAMILY) os.makedirs(fd) with open(os.path.join(fd, "index.html"), "w") as f: - f.write(run_page(FAMILY, plugin_js)) - write_json(os.path.join(fd, "frames.json"), {"interval_ms": INTERVAL_MS, "frames": [fam]}) + f.write(run_page(FAMILY, plugin_js, "family.html")) + write_json(os.path.join(fd, "frames.json"), {"interval_ms": INTERVAL_MS, "frames": [{"t": fam["t"], "state": fam["state"]}]}) + write_json(os.path.join(fd, "family.json"), family_json(root, fam["index"])) famdir, ffiles = os.path.join(out, "family", FAMILY), os.path.join(root, FAMILY) os.makedirs(famdir) order, members, _ = pedigree_svg.load(ffiles) diff --git a/tests/demo/render_pictures.py b/tests/demo/render_pictures.py index 042b7139..795eccc1 100755 --- a/tests/demo/render_pictures.py +++ b/tests/demo/render_pictures.py @@ -22,7 +22,8 @@ as the docs workflow publishes it: built by tests/demo/build_live_demo.py on a DEMO-001 it writes itself (--genome-dir is not read), at the - last moment of its replay, in a 1280 x 1000 window + last moment of its replay, on the person page's + doctor tab (#q/clinical), in a 1280 x 1000 window Every PNG is written again from its pixels alone, so it holds no text, time or EXIF chunk. Needs playwright (with its Chromium) and pillow; @@ -122,12 +123,13 @@ def live_picture(out): # all 'self', so the picture is the same. page = browser.new_page(viewport={"width": 1280, "height": 1000}, device_scale_factor=1, color_scheme="dark", bypass_csp=True) - page.goto(f"http://127.0.0.1:{srv.server_address[1]}/runs/{SAMPLE}/") + page.goto(f"http://127.0.0.1:{srv.server_address[1]}/runs/{SAMPLE}/#q/clinical") page.wait_for_function("document.documentElement.dataset.pgpStatus === 'ready'") # The last moment: the run ended, the charts are drawn, the re-check is in. page.evaluate("() => { const s = document.getElementById('replay-slider'); s.value = s.max; " "s.dispatchEvent(new Event('input')); }") - page.wait_for_function("Number(document.documentElement.dataset.pgpCharts) > 0") + page.wait_for_function("Number(document.documentElement.dataset.pgpCharts) > 0 " + "&& Number(document.documentElement.dataset.pgpResults) > 0") page.wait_for_timeout(1000) path = os.path.join(out, "demo-live-site.png") page.screenshot(path=path) diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index efc760d8..33c54dee 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -12,16 +12,21 @@ once the report step has ended, the re-check only after the run; 4. the run-health card measures the invented machine, never the machine that builds the demo, and no frame holds a path of that machine; - 5. every link a frame gives is relative and reaches a file of the demo; + 5. every link a frame gives is relative and reaches a file of the demo, and + so is every link of runs.json, results.json and family.json; 6. every page the build writes carries the server's Content-Security-Policy as a meta tag, and no page or script of the demo loads anything from outside; 7. in a headless Chrome (skipped without Chrome, except in CI), served as a - static host serves it (no policy header): the runs list, the replay to its - last frame, the charts, a search, the re-check banner, the run-health - card, the family link and its pedigree, the viewer page, and no policy - violation. -Checks 1, 2, 3 and 5 also run against a planted mistake and must fail there (2: + static host serves it (no policy header): the home page's people and + family, the replay to its last frame, the charts, a search, the re-check + banner, the run-health card, the family link and its pedigree, the viewer + page, and no policy violation; the person page's cards (the doctor tab + with the ClinVar card, its caveat and its chart), the tab row moved with + the arrow keys, #s/clinvar focusing its card; the family page with a + clickable pedigree and no step map or report link; and at a phone's width + (390 px) no page scrolls sideways. +Checks 1, 2, 3, 5 and the phone width also run against a planted mistake and must fail there (2: a build with one plugin's state hook dropped): a check that cannot fail is no check. Run: python3 tests/test_live_demo.py @@ -144,6 +149,27 @@ def link_problems(out, built): return bad +def json_link_problems(out): + """Links of runs.json, results.json and family.json that are absolute or reach no file of the demo.""" + bad = [] + for rel, base in (("runs.json", out), (os.path.join("runs", S, "results.json"), os.path.join(out, "runs", S)), + (os.path.join("runs", FAM, "family.json"), os.path.join(out, "runs", FAM))): + with open(os.path.join(out, rel)) as f: + data = json.load(f) + found = list(links(data)) + if rel.endswith("results.json"): + found += [x for ch in data["chapters"] for c in ch["sections"] for x in c.get("links") or []] + if not found: + bad.append(f"{rel}: no link at all") + for u in found: + if u.startswith("https://"): # an explore link of the report (a haplogroup tree), opened by the reader + continue + p = os.path.normpath(os.path.join(base, u)) + if u.startswith("/") or not (os.path.isfile(p) or os.path.isfile(os.path.join(p, "index.html"))): + bad.append(f"{rel}: {u}") + return bad + + def located_term(out): """A term of the demo's search index with a mention at a locus (its hit links the viewer).""" with open(os.path.join(out, "runs", S, f"{S}_search_index.json")) as f: @@ -175,9 +201,12 @@ def browser_checks(out): b = Chrome(chrome, os.path.join(os.path.dirname(out), "chrome")) try: b.go(f"{base}/") - runs = b.wait("document.querySelectorAll('ul.runs a').length === 2 && " - "Array.from(document.querySelectorAll('ul.runs a')).map(function (a) { return a.getAttribute('href'); }).join(' ')") - check("browser: the runs list links the sample and the family", runs == f"runs/{S}/ runs/{FAM}/", runs) + people = b.wait("document.documentElement.dataset.pgpStatus === 'ready' && document.documentElement.dataset.pgpPeople") + runs = b.eval("Array.from(document.querySelectorAll('.person-card a.person-link')).map(function (a) { " + "return a.getAttribute('href'); }).join(' ') + ' | ' + Array.from(document.querySelectorAll(" + "'.family-head a')).map(function (a) { return a.getAttribute('href'); }).join(' ')") + check("browser: the home page shows both people of the family, the sequenced one with a run linked, and the family", + people == "2" and runs == f"runs/{S}/ | runs/{FAM}/ runs/{FAM}/", (people, runs)) b.go(f"{base}/runs/{S}/") first = b.wait("document.documentElement.dataset.pgpFrame && document.documentElement.dataset.pgpStatus === 'ready' " "&& document.querySelectorAll('#map g.box').length") @@ -231,12 +260,77 @@ def browser_checks(out): check("browser: the policy blocked nothing on the sample page", not blocked, blocked) csp = b.eval("var m = document.querySelector('meta[http-equiv=Content-Security-Policy]'); m && m.content") check("browser: the page runs under the meta policy", csp == build_live_demo.CSP, csp) + person_checks(b, base) + phone_checks(b, base) finally: b.close() srv.shutdown() srv.server_close() +def person_checks(b, base): + """The person page at the end of the run: cards, keyboard, a section link; the family page's own view.""" + b.eval("location.hash = '#q/clinical'; true") + n = b.wait("document.documentElement.dataset.pgpTab === 'clinical' && document.documentElement.dataset.pgpResults") + card = b.eval("var c = document.getElementById('card-clinvar'); c && [!!c.querySelector('.caveat .caveat-lead'), " + "!!c.querySelector('figure[data-chart]'), c.classList.contains('attn')].join(' ')") + check("browser: the doctor tab shows its cards; ClinVar's has its caveat, its chart and its flag", + n and int(n) > 3 and card == "true true true", (n, card)) + b.eval("var t = document.querySelector('#rail [aria-selected=true]'); t.focus(); " + "t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); true") + tab = b.wait("document.documentElement.dataset.pgpTab !== 'clinical' && document.documentElement.dataset.pgpTab") + check("browser: the arrow key moves to the next chapter", tab == "carrier", tab) + b.eval("location.hash = '#s/clinvar'; true") + focus = b.wait("document.documentElement.dataset.pgpTab === 'clinical' && document.activeElement && " + "document.activeElement.id") + check("browser: #s/clinvar opens the doctor tab and focuses the ClinVar card", focus == "card-clinvar", focus) + b.go(f"{base}/runs/{FAM}/") + fam = b.wait("document.documentElement.dataset.pgpStatus === 'ready' && [!document.getElementById('map'), " + "!document.getElementById('report-link'), (document.querySelector('svg.pedigree-svg a') || {" + "getAttribute: function () { return null; } }).getAttribute('href')].join(' ')") + check("browser: the family page has a clickable pedigree, no step map and no report link", + fam == f"true true ../../runs/{S}/", fam) + + +def too_wide(b): + return b.eval("document.documentElement.scrollWidth > window.innerWidth ? " + "document.documentElement.scrollWidth + ' > ' + window.innerWidth : ''") + + +def phone_checks(b, base): + # mobile False: a mobile viewport zooms out to fit a page that overflows, which would hide the overflow. + b.call("Emulation.setDeviceMetricsOverride", {"width": 390, "height": 844, "deviceScaleFactor": 2, "mobile": False}, + b.session) + try: + wide = {} + for name, path, ready in (("home", "/", "document.documentElement.dataset.pgpStatus === 'ready'"), + ("person", f"/runs/{S}/#q/clinical", "document.documentElement.dataset.pgpResults"), + ("family", f"/runs/{FAM}/", "document.documentElement.dataset.pgpStatus === 'ready'")): + b.go(base + path) + b.wait(ready) + if name == "person": + b.eval("var s = document.getElementById('replay-slider'); s.value = s.max; " + "s.dispatchEvent(new Event('input')); true") + b.wait("document.documentElement.dataset.pgpFrame === document.getElementById('replay-slider').max") + time.sleep(1) + # Every tab of the person page, not only the one the link opens. + tabs = b.eval("Array.prototype.map.call(document.querySelectorAll('[role=tab][data-tab]'), " + "function (t) { return t.getAttribute('data-tab'); }).join(' ')") or "" + for tab in tabs.split(): + b.eval(f"document.querySelector('[role=tab][data-tab=\"{tab}\"]').click(); true") + time.sleep(0.3) + if too_wide(b): + wide[f"person {tab}"] = too_wide(b) + wide["person tabs"] = "" if len(tabs.split()) >= 3 else f"only {tabs!r}" + wide[name] = too_wide(b) + check("browser: at 390 px no page scrolls sideways", not any(wide.values()), wide) + b.eval("var d = document.createElement('div'); d.style.width = '900px'; d.style.height = '4px'; " + "document.querySelector('main').appendChild(d); true") + control("a 900 px wide block on the family page", not too_wide(b)) + finally: + b.call("Emulation.clearDeviceMetricsOverride", {}, b.session) + + def main(): work = tempfile.mkdtemp(prefix="test_live_demo.") try: @@ -289,6 +383,15 @@ def main(): planted = copy.deepcopy(built) planted["family"]["state"]["plugins"]["family"]["pedigree"] = f"/family/{FAM}/pedigree.svg" control("an absolute pedigree link", not link_problems(out, planted)) + bad = json_link_problems(out) + check("5. every link of runs.json, results.json and family.json is relative and reaches a file", not bad, bad) + with open(os.path.join(out, "runs.json")) as f: + idx = json.load(f) + with open(os.path.join(out, "runs.json"), "w") as f: + json.dump(dict(idx, runs=[dict(r, url="/" + r["url"]) for r in idx["runs"]]), f) + control("an absolute link in runs.json", not json_link_problems(out)) + with open(os.path.join(out, "runs.json"), "w") as f: + json.dump(idx, f) # 6. the policy on every page, nothing from outside pages, texts = [], {} diff --git a/tests/test_live_results.py b/tests/test_live_results.py new file mode 100644 index 00000000..b1a9bc5e --- /dev/null +++ b/tests/test_live_results.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""A person's results and a family's page on the live site: bin/live_results.py, the results plugin +(bin/owngenome_live_results.py), /runs.json and the family plugin's family.json. + +Checks: + 1. render_report.SECTION_NOTES names a caveat for every section id of CHAPTERS, + and each name is a text constant of bin/render_report.py; + 2. results.json of DEMO-001 (tests/demo/make_demo_sample.py, collect_summary): + the seven chapters with every section id once, and each card's caveat is + 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; + 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); + 5. through the real server: /runs//results.json is behind the login + (401 without a session), answers the cards with one, and writes one access + log row per request; summary.json stays 404; /runs.json lists the run with + its progress and attention count, behind the login too; + 6. APOE: the Traits card's hidden text, behind its warning, is each APOE row + as the report prints it (trait, rsID, genotype, reading), never a dict; + 7. the adapters carry what the report says of a section, on small made-up + sections: the ancient PCA plot (base64 in the summary) as an image, the + Slivar phase lines, every Exomiser variant of a gene, a not-assessed + secondary-findings list, a carrier list not made and an unparsed PharmCAT + report as sentences (never zero counts), an unsized "normal" locus as not + sized, AnnotSV's exon-edge check, a genotype rule and a deep intronic call. +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. A check +that cannot fail is no check. + +Run: python3 tests/test_live_results.py +""" +import contextlib +import base64 +import copy +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import urllib.parse + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "bin")) +sys.path.insert(0, os.path.join(REPO, "tests")) +import collect_summary # noqa: E402 +import live_results # noqa: E402 +import owngenome_live # noqa: E402 +import owngenome_live_family # noqa: E402 +import render_report as rr # noqa: E402 + +SAMPLE = "DEMO-001" +ASSETS = os.path.join(REPO, "assets", "live") +FAMILIES = os.path.join(REPO, "tests", "fixtures", "families_live") +PASSWORD = "synthetic test password 7" +FAILS = [] + + +def check(desc, ok, detail=""): + print(("[PASS] " if ok else "[FAIL] ") + desc + ("" if ok else f" -- {str(detail)[:800]}")) + if not ok: + FAILS.append(desc) + + +def control(desc, still_passes): + check(f"control: {desc}", not still_passes, "the check passed on the planted mistake") + + +# --- 1. the caveat of every section --------------------------------------------------------- + +def notes_problems(chapters, notes, module): + ids = [sid for _, _, _, secs in chapters for sid, _, _ in secs] + out = [f"{sid}: no caveat" for sid in ids if not notes.get(sid)] + out += [f"{sid}: {n} is not a text constant" for sid, names in notes.items() for n in names + if not isinstance(getattr(module, n, None), str) or not getattr(module, n).strip()] + out += [f"{sid}: no such section" for sid in notes if sid not in ids] + return out + + +# --- 2. one card per section, its caveat word for word -------------------------------------- + +def card_problems(res): + out = [] + want = [sid for _, _, _, secs in rr.CHAPTERS for sid, _, _ in secs] + got = [c["id"] for ch in res["chapters"] for c in ch["sections"]] + if got != want: + out.append(f"sections {got} != {want}") + if [ch["key"] for ch in res["chapters"]] != [k for k, *_ in rr.CHAPTERS]: + out.append("chapters out of the report's order") + for ch in res["chapters"]: + for c in ch["sections"]: + exp = [getattr(rr, n) for n in rr.SECTION_NOTES.get(c["id"], ())] + if not c["caveat"] or c["caveat"] != exp: + out.append(f"{c['id']}: caveat {c['caveat']!r:.120}") + return out + + +# --- 3. the wording gate -------------------------------------------------------------------- + +def asset_text(path): + """What a page or script of assets/live puts on screen: the text of an HTML page, the string + literals of a script (its comments and code left out).""" + with open(path) as f: + t = f.read() + if path.endswith(".html"): + t = re.sub(r"<(script|style)\b.*?", " ", t, flags=re.S | re.I) + return [x.strip() for x in re.split(r"<[^>]*>", t) if x.strip()] + t = re.sub(r"/\*.*?\*/", " ", t, flags=re.S) + t = re.sub(r"(?m)^\s*//[^\n]*", " ", t) + return [m.group(2) for m in re.finditer(r"(['\"])((?:\\.|(?!\1).)*)\1", t)] + + +def asset_problems(texts): + out = [] + for name, items in texts.items(): + for s in items: + if live_results.FORBIDDEN.search(re.sub(r'"[^"]*"', "", s)): + out.append(f"{name}: {s[:120]}") + return out + + +# --- 4. ClinVar with and without hits ------------------------------------------------------- + +def clinvar_card(summary): + res = live_results.build(SAMPLE, summary) + return next(c for ch in res["chapters"] for c in ch["sections"] if c["id"] == "clinvar") + + +def clinvar_problems(with_hits, without): + out = [] + n = len(with_hits["table"]["rows"]) if with_hits.get("table") else 0 + if not n: + out.append("hits but no table") + if without.get("table"): + out.append("no hit but a table") + if "lists only part" not in (without.get("empty_text") or ""): + out.append(f"empty text {without.get('empty_text')!r}") + return out + + +# --- 6. APOE behind its warning ------------------------------------------------------------- + +APOE_ROW = {"rsid": "rs429358", "gene": "APOE", "trait": "APOE e4 marker", "genotype": "T/C", + "status": "called", "reading": "one copy of the e4 allele", "ancestry": "", "note": "", "pmids": "1"} + + +def apoe_problems(hidden): + out = [] + text = (hidden or {}).get("text") or "" + if "{" in text or "'rsid'" in text: + out.append(f"a dict in the text {text!r}") + wants = [APOE_ROW["trait"], APOE_ROW["rsid"], APOE_ROW["genotype"], APOE_ROW["reading"]] + for want in wants: + if want not in text: + out.append(f"{want!r} missing from {text!r}") + return out + + +# --- 7. the adapters on made-up sections ----------------------------------------------------- + +def adapter_sections(): + sv = {"type": "DEL", "sv": "chr1:100-5000", "gt": "0/1", "length": 4900, "acmg_class": 5, "genes": ["GENE1"], + "exon_edge": "yes", "exon_edge_detail": "GENE1 exon 2", "population_af": None, "population_source": "", + "common": False, "support": None} + return { + "ancient": {"state": "ok", "data": {"nearest": [], "sites_used": 1, "panel_sites": 2, + "plot_svg": base64.b64encode(b"").decode()}}, + "slivar": {"state": "ok", "data": {"prioritized": 2, "compound_het_variants": 2, "compound_het_genes": 1, + "compound_het_pairs": 1, "compound_het_phase": {"trans": 1}, + "compound_het_trans": [{"gene": "GENE2", "variants": "v1 v2", "reads": 3}]}}, + "exomiser": {"state": "ok", "data": {"genes": [{"rank": 1, "gene": "GENE3", "variants": [ + {"hgvs": "c.1A>G"}, {"hgvs": "c.2C>T"}]}]}}, + "clinical": {"state": "ok", "data": {"acmg_sf": {"unavailable": "no VEP output"}}}, + "carrier": {"state": "ok", "data": {"unavailable": "no ClinVar screen"}}, + "cpic": {"state": "ok", "data": {"parse_failed": True, "genes": [], "warnings": []}}, + "expansions": {"state": "ok", "data": {"stranger": True, "loci": [ + {"locus": "LOCUS1", "repeat_count": "20/30", "status": "normal", "sizing": "flanking_only"}]}}, + "annotsv": {"state": "ok", "data": {"count": 1, "rows": [sv]}}, + "traits": {"state": "ok", "data": {"rows": [], "rules": [ + {"trait": "Rule trait", "gene": "GENE4", "rsids": "rs1,rs2", "status": "called", "result": "AB", + "phase": "read_backed", "reading": "rule reading"}]}}, + "clinvar": {"state": "ok", "data": {"hits": [], "deep_intronic": [ + {"gene": "GENE5", "hgvs": "c.100+500A>G", "chrom": "chr1", "pos": 1, "ref": "A", "alt": "G", + "call": "het", "detail": "GQ 40"}]}}, + } + + +def adapter_problems(adapters): + S, out = adapter_sections(), [] + def run(name, sec=None): + try: + return adapters[name](S, {"sections": S}) or {} + except Exception as e: # a crash is a problem, not a test error + out.append(f"{name}: {type(e).__name__} {e}") + return {} + text = lambda got: json.dumps(got) # noqa: E731 + if not (run("ancient").get("image") or {}).get("src", "").endswith(S["ancient"]["data"]["plot_svg"]): + out.append("ancient: no plot image") + if "in trans on 3 reads" not in text(run("slivar")): + out.append("slivar: no phase lines") + if len((run("exomiser").get("table") or {}).get("rows") or []) != 2: + out.append("exomiser: not every variant") + got = run("clinical_filter") + if "not assessed" not in text(got) or "ClinVar P/LP in its genes" in text(got): + out.append(f"clinical: not-assessed not said {got}") + got = run("carrier") + if "not made" not in text(got) or got.get("facts"): + out.append(f"carrier: not-made not said {got}") + got = run("cpic") + if "could not be parsed" not in text(got) or got.get("facts"): + out.append(f"cpic: parse failure not said {got}") + rows = (run("expansions").get("table") or {}).get("rows") or [[]] + if "not sized" not in rows[0]: + out.append(f"expansions: unsized locus shown as {rows[0]}") + if "on exon edges" not in text(run("structural")): + out.append("structural: no exon-edge check") + if "Rule trait" not in text(run("traits")): + out.append("traits: no genotype rule") + if "GENE5" not in text(run("clinvar")): + out.append("clinvar: no deep intronic call") + return out + + +# --- 5. the server -------------------------------------------------------------------------- + +def request(port, method, path, body=None, headers=None): + import http.client + c = http.client.HTTPConnection("127.0.0.1", port, timeout=30) + h = {"Host": f"127.0.0.1:{port}"} + h.update(headers or {}) + c.request(method, path, body=body, headers=h) + r = c.getresponse() + data = r.read() + hdr = {k.lower(): v for k, v in r.getheaders()} + c.close() + return r.status, hdr, data + + +def serve(root, state, pwfile, open_results=False): + app = owngenome_live.App() + if open_results: # the planted mistake: the results route without the login + route = app.route + app.route = lambda m, p, fn, auth=True: route(m, p, fn, auth and "results" not in p) + with contextlib.redirect_stderr(io.StringIO()): + live, web, ingest = owngenome_live.start(["--root", root, "--state-dir", state, "--port", "0", "--ingest-port", "0", + "--password-file", pwfile, "--quiet"], app) + threading.Thread(target=web.serve_forever, daemon=True).start() + return live, web, ingest + + +def stop(srv): + live, web, ingest = srv + live.stopping = True + web.shutdown() + web.server_close() + ingest.server_close() + + +def server_problems(port, state): + out = [] + path = f"/runs/{SAMPLE}/results.json" + code, _, _ = request(port, "GET", path) + if code != 401: + out.append(f"results.json without a session: {code}") + code, _, _ = request(port, "GET", "/runs.json") + if code != 401: + out.append(f"runs.json without a session: {code}") + code, h, _ = request(port, "POST", "/login", urllib.parse.urlencode({"password": PASSWORD, "next": "/"}), + {"Content-Type": "application/x-www-form-urlencoded"}) + m = re.match(r"owngenome_live=([^;]+)", h.get("set-cookie", "")) + if not m: + return out + [f"login {code}"] + ck = {"Cookie": f"owngenome_live={m.group(1)}"} + log = os.path.join(state, "access.log") + before = open(log).read().count("\tresults\t") if os.path.isfile(log) else 0 + code, h, body = request(port, "GET", path, headers=ck) + res = json.loads(body) if code == 200 else {} + if code != 200 or len(res.get("chapters") or []) != 7 or res.get("sample") != SAMPLE: + out.append(f"results.json with a session: {code} {str(res)[:200]}") + after = open(log).read().count("\tresults\t") if os.path.isfile(log) else 0 + if after - before != 1: + out.append(f"{after - before} access log rows for one results.json") + code, _, _ = request(port, "GET", f"/runs/{SAMPLE}/summary.json", headers=ck) + if code != 404: + out.append(f"summary.json served: {code}") + code, _, body = request(port, "GET", "/runs.json", headers=ck) + idx = json.loads(body) if code == 200 else {} + run = next((r for r in idx.get("runs") or [] if r["name"] == SAMPLE), None) + if not run or run["url"] != f"/runs/{SAMPLE}/" or not run.get("results") or run.get("attention") != res.get("attention"): + out.append(f"runs.json: {code} {run}") + return out + + +def main(): + work = tempfile.mkdtemp(prefix="test_live_results.") + srvs = [] + try: + # 1. + bad = notes_problems(rr.CHAPTERS, rr.SECTION_NOTES, rr) + check(f"1. every section id has its caveat ({len(rr.SECTION_NOTES)} ids)", not bad, bad) + planted = {k: v for k, v in rr.SECTION_NOTES.items() if k != "vntyper"} + control("VNTYPER's caveat entry dropped", not notes_problems(rr.CHAPTERS, planted, rr)) + + # 2. + root = os.path.join(work, "root") + os.makedirs(root) + subprocess.run([sys.executable, os.path.join(REPO, "tests", "demo", "make_demo_sample.py"), "--genome-dir", root, + "--records", "2000"], check=True, stdout=subprocess.DEVNULL) + d = os.path.join(root, SAMPLE) + summary = collect_summary.collect(SAMPLE, d) + with open(os.path.join(d, "summary.json"), "w") as f: + json.dump(summary, f) + res = live_results.build(SAMPLE, summary) + bad = card_problems(res) + check("2. results.json: the report's chapters, one card per section, each with its caveats word for word", + not bad, bad) + blank = copy.deepcopy(res) + blank["chapters"][3]["sections"][0]["caveat"] = [] + control("one card's caveat blanked", not card_problems(blank)) + + # 3. + bad = live_results.wording_problems(res) + check("3. no string of results.json reads as an all-clear", not bad, bad) + fam = owngenome_live_family.family_page("family_a", os.path.join(FAMILIES, "family_a")) + bad = live_results.wording_problems(fam) + check("3. no string of family.json reads as an all-clear", not bad and fam.get("layout"), bad) + planted = copy.deepcopy(res) + planted["chapters"][0]["sections"][0]["headline"] = "Coverage: ok" + control("'ok' planted in a card headline", not live_results.wording_problems(planted)) + texts = {} + for p, _, fs in os.walk(ASSETS): + for f in fs: + if f.endswith((".html", ".js")): + texts[os.path.relpath(os.path.join(p, f), ASSETS)] = asset_text(os.path.join(p, f)) + bad = asset_problems(texts) + check(f"3. no page or script text of assets/live reads as an all-clear ({len(texts)} files)", + not bad and len(texts) >= 10, bad) + planted = dict(texts, **{"person.js": texts["person.js"] + ["All clear: nothing to see"]}) + control("'All clear' planted in person.js", not asset_problems(planted)) + + # 4. + none = copy.deepcopy(summary) + cv = none["sections"]["clinvar"]["data"] + cv["hits"], cv["count"], cv["by_stars"] = [], 0, {} + with_hits, without = clinvar_card(summary), clinvar_card(none) + bad = clinvar_problems(with_hits, without) + check("4. ClinVar: a table with hits; without, the sentence that ClinVar lists only part", not bad, bad) + control("the demo's hits emptied on both sides", not clinvar_problems(without, without)) + + # 6. + S = {"traits": {"data": {"rows": [], "called": 0, "not_covered": 0, "apoe": [APOE_ROW]}}} + hidden = live_results.a_traits(S, SAMPLE).get("hidden") + bad = apoe_problems(hidden) + check("6. APOE: the hidden text reads each row as the report does, never a dict", not bad, bad) + control("the APOE row printed as its Python dict", not apoe_problems({"text": str(APOE_ROW)})) + + # 7. + names = {"ancient": "a_ancient", "slivar": "a_slivar", "exomiser": "a_exomiser", + "clinical_filter": "a_clinical_filter", "carrier": "a_carrier", "cpic": "a_cpic", + "expansions": "a_expansions", "structural": "a_structural", "traits": "a_traits", + "clinvar": "a_clinvar"} + adapters = {k: getattr(live_results, v) for k, v in names.items()} + bad = adapter_problems(adapters) + check("7. the adapters carry the report's plot, phase, variants, rules and not-assessed sentences", not bad, bad) + control("the Slivar adapter returning nothing", not adapter_problems(dict(adapters, slivar=lambda S, s: {}))) + + # 5. + pwfile = os.path.join(work, "password") + with open(pwfile, "w") as f: + f.write(owngenome_live.hash_password(PASSWORD) + "\n") + state = os.path.join(work, "state") + srv = serve(root, state, pwfile) + srvs.append(srv) + bad = server_problems(srv[1].server_address[1], state) + check("5. results.json and runs.json behind the login, one log row, summary.json 404", not bad, bad) + state2 = os.path.join(work, "state-open") + srv = serve(root, state2, pwfile, open_results=True) + srvs.append(srv) + control("the results route registered without the login", + not server_problems(srv[1].server_address[1], state2)) + finally: + for s in srvs: + stop(s) + shutil.rmtree(work, ignore_errors=True) + if FAILS: + print(f"\n{len(FAILS)} check(s) failed") + return 1 + print("\nAll checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_output_contract.py b/tests/test_output_contract.py index 38c6de8a..8e896754 100644 --- a/tests/test_output_contract.py +++ b/tests/test_output_contract.py @@ -5,9 +5,14 @@ run-all.sh switch and published Nextflow file has its row, and every row still matches the code; 2. static controls, each on a copy of the code: a renamed published file, a - removed param, a removed switch, a new --tools name with no row and a new - published file with no row are each refused, and the stable ones are - named BREAKING; + removed param, a removed switch, a new --tools name with no row, a new + published file with no row and a schema default that is not + nextflow.config's are each refused, and the stable ones are named + BREAKING; so are, on a copy of the contract, a file row in the folder of + a publishDir whose saveAs drops it and a nextflow row whose step script + writes the same path (also on a line of seven shell variables); + --pedigree is a declared param; a saveAs closure is read up to its own + brace and a script path counts only as a whole path; 3. the run check on a planted results folder made from the contract's own e2e rows passes for both legs; then a dropped column, a dropped VCF field, a dropped JSON key and a renamed file are each refused, naming the @@ -63,11 +68,30 @@ def code_copy(tmp): dst = os.path.join(tmp, "repo") shutil.copytree(os.path.join(REPO, "modules"), os.path.join(dst, "modules")) os.makedirs(os.path.join(dst, "scripts")) - for f in ("main.nf", "nextflow_schema.json", os.path.join("scripts", "run-all.sh")): + for f in ("main.nf", "nextflow_schema.json", "nextflow.config"): shutil.copy(os.path.join(REPO, f), os.path.join(dst, f)) + for f in os.listdir(os.path.join(REPO, "scripts")): + if f.endswith(".sh"): + shutil.copy(os.path.join(REPO, "scripts", f), os.path.join(dst, "scripts", f)) return dst +def contract_copy(tmp, name, change): + """The contract with one row changed: change(cells) edits the cells of the file row called name.""" + path = os.path.join(tmp, "contract.tsv") + hit = False + with open(CONTRACT) as f, open(path, "w") as g: + for line in f: + c = line.rstrip("\n").split("\t") + if c[0] == "file" and c[1] == name: + change(c) + line = "\t".join(c) + "\n" + hit = True + g.write(line) + assert hit, f"no file row {name}" + return path + + def edit(path, old, new, count=1): src = open(path).read() assert old in src, f"{old!r} not in {path}" @@ -96,6 +120,9 @@ def static_controls(): lambda d: edit(os.path.join(d, "modules", "local", "roh", "main.nf"), 'path("*_roh.txt"),', 'path("*_roh.txt"),\n tuple val(meta), path("*_roh_extra.tsv"), emit: extra\n'), [r"ROH .* writes \*_roh_extra\.tsv and has no contract row"]), + ("a schema default that is not nextflow.config's (traits left out of --tools)", + lambda d: edit(os.path.join(d, "nextflow_schema.json"), "mito_variants,traits,html_report", "mito_variants,html_report"), + [r"param tools: nextflow_schema\.json's default .* is not nextflow\.config's"]), ] for desc, change, want in cases: tmp = tempfile.mkdtemp() @@ -109,6 +136,39 @@ def static_controls(): shutil.rmtree(tmp) + def to_aligned(c): + c[1] = "{sample}/aligned/{sample}_read_count.tsv" + + def to_nextflow(c): + c[4] = "nextflow" + + contract_cases = [ + ("the read count in aligned/, whose publishDir drops it (it is published to qc/)", + "{sample}/qc/{sample}_read_count.tsv", to_aligned, + [r"BREAKING: the stable file \{sample\}/aligned/\{sample\}_read_count\.tsv .* is not what ALIGN_MINIMAP2 publishes"]), + ("legs nextflow on a file step 49 writes under the same name", + "{sample}/chip/{sample}_chip.tsv", to_nextflow, + [r"\{sample\}/chip/\{sample\}_chip\.tsv .* has legs nextflow, but scripts/49-chip\.sh writes it"]), + ("legs nextflow on a file step 50 writes on a line of seven shell variables", + "{sample}/mei/{sample}_megane.log", to_nextflow, + [r"\{sample\}/mei/\{sample\}_megane\.log .* has legs nextflow, but scripts/50-mei\.sh writes it"]), + ] + for desc, name, change, want in contract_cases: + tmp = tempfile.mkdtemp() + try: + rc, out = run("--static", "--contract", contract_copy(tmp, name, change)) + missing = [w for w in want if not re.search(w, out)] + check(f"static control refused: {desc}", rc == 1 and not missing, f"rc={rc}, missing {missing}: {out[-1500:]}") + finally: + shutil.rmtree(tmp) + + config = open(os.path.join(REPO, "nextflow.config")).read() + schema = json.load(open(os.path.join(REPO, "nextflow_schema.json"))) + in_schema = any("pedigree" in g.get("properties", {}) for g in schema["definitions"].values()) + check("--pedigree is declared in nextflow.config and nextflow_schema.json", + re.search(r"^\s*pedigree\s*=\s*null\b", config, re.M) is not None and in_schema) + + def _drop_param(d, name): path = os.path.join(d, "nextflow_schema.json") s = json.load(open(path)) @@ -210,6 +270,29 @@ def run_controls(): shutil.rmtree(tmp) +def reader_controls(): + """The two readers the static check is built on, on inputs the code does not have yet.""" + import importlib.util + spec = importlib.util.spec_from_file_location("contract_check", CHECK) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + stmt = "publishDir \"x\", saveAs: { fn -> fn.endsWith('.log') ? null : fn }, enabled: { true }" + body = m.closure_body(stmt) + check("a saveAs closure ends at its own brace (an option after it is not part of it)", + body is not None and "enabled" not in body and m.save_as(body, "a.log") == "drop", repr(body)) + tmp = tempfile.mkdtemp() + try: + os.makedirs(os.path.join(tmp, "scripts")) + open(os.path.join(tmp, "scripts", "11-roh.sh"), "w").write( + 'S="${GENOME_DIR}/${SAMPLE}"\nbgzip -c x > "${S}/roh/${SAMPLE}_roh.txt.gz"\n') + short = m.bash_writes(tmp, "11", "{sample}/roh/{sample}_roh.txt", {}) + full = m.bash_writes(tmp, "11", "{sample}/roh/{sample}_roh.txt.gz", {}) + check("a step script writing x.txt.gz does not count as writing x.txt", + short is None and full == "scripts/11-roh.sh", f"{short!r} {full!r}") + finally: + shutil.rmtree(tmp) + + def malformed(): tmp = tempfile.mkdtemp() try: @@ -228,6 +311,7 @@ def malformed(): static_controls() +reader_controls() run_controls() malformed() print(f"{FAILS} failed")