diff --git a/README.md b/README.md index 8920c1e7..8b801a65 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The full documentation is at **https://geiserx.github.io/OwnGenome/**, one page - [Vendor compatibility guide](https://geiserx.github.io/OwnGenome/vendor-guide/): what each provider delivers and how to get it - [Pipeline overview](https://geiserx.github.io/OwnGenome/pipeline-overview/): every step with its tool and image, which ones a default run includes, and the page for each step - [Nextflow](https://geiserx.github.io/OwnGenome/nextflow/): the workflow runner, parallel steps and resume -- [Watch a run live](https://geiserx.github.io/OwnGenome/live-site/): a results page per person, laid out by the report's chapters, a page per family, and the step map in a Progress tab while the run goes, behind a password. The [demo](https://geiserx.github.io/OwnGenome/demo/) replays an invented run +- [Watch a run live](https://geiserx.github.io/OwnGenome/live-site/): each genome as a report that leads with what to take to a doctor, a page per finding, a report per family, and a timeline of the steps while the run goes, behind a password. The [demo](https://geiserx.github.io/OwnGenome/demo/) replays an invented run - [Interpreting your results](https://geiserx.github.io/OwnGenome/interpreting-results/): what each report means and what to do with it - [Multi-sample comparison](https://geiserx.github.io/OwnGenome/multi-sample/): partners, siblings, parents - [Long-read guide](https://geiserx.github.io/OwnGenome/long-read-guide/): Nanopore and PacBio HiFi diff --git a/assets/live/core.js b/assets/live/core.js index 64ec443f..b6e4ff07 100644 --- a/assets/live/core.js +++ b/assets/live/core.js @@ -1,6 +1,6 @@ -// core.js: what the live site's pages share (home.html, index.html, family.html). Element builders, +// core.js: what the live site's pages share (home.html, index.html, finding.html, family.html). Element builders, // a fetch that sends the session and goes to the login on a 401, the pedigree symbol, dates, the -// caveat block, the facts list and the table. Text only through textContent and createElementNS: +// caveat block, the facts list, the table, and the Journal's level marks and chromosome map. Text only through textContent and createElementNS: // no innerHTML, no inline script, no eval. Everything hangs on window.PGPUI. (function () { 'use strict'; @@ -124,6 +124,92 @@ }); } + // --- the Journal's shared marks: the level glyph and word, and the chromosome map of findings --- + var LEVEL_WORDS = { act: 'Take to a doctor', medicine: 'Tell your prescriber', review: 'Worth a look', + carrier: 'For family planning', trust: 'Data quality', info: 'For information' }; + var LEVEL_SHORT = { act: 'doctor', medicine: 'prescriber', review: 'look', carrier: 'family', trust: 'quality', info: 'info' }; + var LEVEL_ORDER = ['act', 'medicine', 'review', 'carrier', 'trust', 'info']; + // A level is always its glyph and a word, never a colour alone. + function level(lv, words, extra) { + var s = el('span', { 'class': 'lv ' + lv + (extra ? ' ' + extra : ''), 'data-level': lv }); + add(s, el('span', { 'class': 'g', 'aria-hidden': 'true' }), words === undefined ? LEVEL_WORDS[lv] || lv : words); + return s; + } + // A finding's level: a data-quality finding is always a problem with the data, so its mark is red. + function findingLevel(lv, words) { return level(lv, words, lv === 'trust' ? 'problem' : undefined); } + // GRCh38: [name, length Mb, centromere Mb]. + var CHR = [['1', 248.96, 123.4], ['2', 242.19, 93.9], ['3', 198.3, 90.9], ['4', 190.21, 50], ['5', 181.54, 48.8], + ['6', 170.81, 59.8], ['7', 159.35, 60.1], ['8', 145.14, 45.2], ['9', 138.39, 43], ['10', 133.8, 39.8], + ['11', 135.09, 53.4], ['12', 133.28, 35.5], ['13', 114.36, 17.7], ['14', 107.04, 17.2], ['15', 101.99, 19], + ['16', 90.34, 36.8], ['17', 83.26, 25.1], ['18', 80.37, 18.5], ['19', 58.62, 26.2], ['20', 64.44, 28.1], + ['21', 46.71, 12], ['22', 50.82, 15], ['X', 156.04, 60.6], ['Y', 57.23, 10.4]]; + var LV_COLOUR = { act: '#d03b3b', medicine: '#ec835a', review: '#fab219', carrier: '#9085e9', trust: '#c3c2b7', info: '#c3c2b7' }; + function glyph(g, lv, x, y) { + var c = LV_COLOUR[lv] || LV_COLOUR.info; + if (lv === 'act') { g.appendChild(svg('rect', { x: x - 4, y: y - 4, width: 8, height: 8, fill: c, transform: 'rotate(45 ' + x + ' ' + y + ')' })); } + else if (lv === 'medicine') { g.appendChild(svg('rect', { x: x - 5.5, y: y - 3, width: 11, height: 6, rx: 3, fill: c })); } + else if (lv === 'review') { g.appendChild(svg('path', { d: 'M' + x + ' ' + (y - 5) + 'L' + (x + 5) + ' ' + (y + 4) + 'L' + (x - 5) + ' ' + (y + 4) + 'Z', fill: c })); } + else if (lv === 'carrier') { + g.appendChild(svg('circle', { cx: x - 2.6, cy: y, r: 3.4, fill: 'none', stroke: c, 'stroke-width': 1.6 })); + g.appendChild(svg('circle', { cx: x + 2.6, cy: y, r: 3.4, fill: 'none', stroke: c, 'stroke-width': 1.6 })); + } else { g.appendChild(svg('circle', { cx: x, cy: y, r: 4, fill: 'none', stroke: c, 'stroke-width': 1.5 })); } + } + // "chr16:2087914" or "chr16:2084000-2096000" -> {chr: '16', mb: 2.08}; null for anything else. + function locus(v) { + var m = /^(?:chr)?([0-9]{1,2}|X|Y):([0-9]+)/.exec(String(v || '')); + return m ? { chr: m[1], mb: Number(m[2]) / 1e6 } : null; + } + // The chromosome map: the 24 chromosomes in a row, each finding a glyph on its chromosome, with its + // name beside it and the same words in a title (and in the list under the figure). + function karyogram(marks, opts) { + opts = opts || {}; + var W = opts.width || 680, H = opts.height || 190, top = 22, bottom = 22; + var slot = (W - 56) / CHR.length, scale = (H - top - bottom) / 249; + var s = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': opts.aria || 'Where the findings sit, by chromosome' }); + CHR.forEach(function (c, i) { + var x = i * slot + slot / 2 - 3.5; + s.appendChild(svg('rect', { 'class': 'chr', x: x, y: top, width: 7, height: Math.max(1, c[2] * scale - 1.2), rx: 3.5 })); + s.appendChild(svg('rect', { 'class': 'chr', x: x, y: top + c[2] * scale + 1.2, width: 7, height: Math.max(1, (c[1] - c[2]) * scale - 1.2), rx: 3.5 })); + var t = svg('text', { 'class': 'lab', x: x + 3.5, y: H - 6, 'text-anchor': 'middle' }); + t.textContent = c[0]; + s.appendChild(t); + }); + (marks || []).forEach(function (m) { + var i = -1; + CHR.forEach(function (c, j) { if (c[0] === m.chr) { i = j; } }); + if (i < 0) { return; } + var x = i * slot + slot / 2 + 7, y = top + Math.min(m.mb, CHR[i][1]) * scale; + var g = svg('g', { 'class': 'mk', tabindex: '0' }); + var tt = svg('title', {}); + tt.textContent = (LEVEL_WORDS[m.lv] || '') + ': ' + (m.tip || m.label); + g.appendChild(tt); + g.appendChild(svg('line', { x1: x - 10.5, x2: x - 1, y1: y, y2: y, stroke: LV_COLOUR[m.lv] || LV_COLOUR.info, 'stroke-width': 2 })); + glyph(g, m.lv, x + 4, y); + if (m.label) { + var t = svg('text', { x: x + 11, y: y + 3.5, stroke: '#0e0f10', 'stroke-width': 4, 'paint-order': 'stroke', 'stroke-linejoin': 'round' }); + t.textContent = m.label; + g.appendChild(t); + } + if (m.href) { + g.setAttribute('role', 'link'); + g.addEventListener('click', function () { window.location.href = m.href; }); + g.addEventListener('keydown', function (e) { if (e.key === 'Enter') { window.location.href = m.href; } }); + } + s.appendChild(g); + }); + return s; + } + // A plain time: "14:22" in UTC, "4 Nov" for a day. + var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + function hm(utc) { var d = new Date(utc); return isFinite(d) ? ('0' + d.getUTCHours()).slice(-2) + ':' + ('0' + d.getUTCMinutes()).slice(-2) : ''; } + function dm(utc) { var d = new Date(utc); return isFinite(d) ? d.getUTCDate() + ' ' + MONTHS[d.getUTCMonth()] : ''; } + function hours(a, b) { + var x = Date.parse(a), y = Date.parse(b); + return isFinite(x) && isFinite(y) && y > x ? (y - x) / 3600e3 : null; + } + window.PGPUI = { el: el, svg: svg, add: add, getJSON: getJSON, avatar: avatar, day: day, caveat: caveat, - facts: facts, table: table, tablist: tablist, firstSentence: firstSentence }; + facts: facts, table: table, tablist: tablist, firstSentence: firstSentence, level: level, findingLevel: findingLevel, glyph: glyph, + karyogram: karyogram, locus: locus, LEVEL_WORDS: LEVEL_WORDS, LEVEL_SHORT: LEVEL_SHORT, LEVEL_ORDER: LEVEL_ORDER, + hm: hm, dm: dm, hours: hours }; }()); diff --git a/assets/live/family-page.js b/assets/live/family-page.js index 605cb319..2856ead5 100644 --- a/assets/live/family-page.js +++ b/assets/live/family-page.js @@ -1,5 +1,6 @@ // family-page.js: a family's page (assets/live/family.html), from family.json -// (bin/owngenome_live_family.py): the pedigree with each sequenced person a link to their page, the +// (bin/owngenome_live_family.py), the Journal design: a report in chapters, with a lead that answers +// "what does it mean for our children?". The pedigree with each sequenced person a link to their page, the // members with their run progress, what steps 38 and 39 planned, each couple's carrier screen in full // (both carry, then X-linked, then one partner), who is related how as a grid (a list of pairs on a // phone), each child's stretches painted by parent, the de novo and compound-het candidates, and the @@ -12,11 +13,60 @@ var el = U.el, add = U.add, root = document.documentElement; var RESULT = { shared: 'Both carry', x_linked: 'X-linked', one_partner: 'One partner' }; + // A chapter: its roman number in brass, its title in the serif. + var CHAPTER = { 'Pedigree': 'I', 'Who is related how': 'II', 'Couple carrier screen': 'III', 'Each child': 'IV', + 'Side by side': 'V', 'Plan and reports': 'App.' }; function section(title, cls) { - var s = el('section', { 'class': 'card ' + (cls || '') }); - add(s, el('h2', {}, title)); + var s = el('section', { 'class': 'card ' + (cls || ''), id: 'ch-' + (cls || 'x') }); + var h = el('h2', {}); + if (CHAPTER[title]) { add(h, el('span', { 'class': 'secnum' }, CHAPTER[title])); } + add(h, title); + add(s, h); return s; } + // Colour follows the person: the members in their order, one colour each, on every family figure. + var PCOL = ['#d95926', '#3987e5', '#199e70', '#c98500', '#9085e9']; + function colourOf(f, sample) { + var i = (f.state.members || []).map(function (m) { return m.sample; }).indexOf(sample); + return i >= 0 ? PCOL[i % PCOL.length] : null; + } + // The four equally likely children of two carriers of one recessive gene: 1 affected, 2 carriers, 1 neither. + // Drawn only when the screen's own chance is 1 in 4: another chance (a partner with two copies) has + // another split, and the row's words say it. + function fourChildren(risk) { + if (!/^\s*1 in 4\b/.test(risk || '')) { return null; } + var box = el('div', { 'class': 'kids4' }); + var g = el('div', { 'class': 'grid4', role: 'img', 'aria-label': 'Each pregnancy: 1 in 4 affected, 2 in 4 carriers, 1 in 4 neither' }); + add(g, el('span', { 'class': 'aff', title: 'affected' }), el('span', { title: 'carrier' }), el('span', { title: 'carrier' }), + el('span', { 'class': 'non', title: 'neither' })); + add(box, g, add(el('div', { 'class': 'kt' }), el('b', {}, '1 in 4'), ' per pregnancy affected', el('br', {}), + '2 in 4 carriers, not affected; 1 in 4 neither')); + return box; + } + // The family's lead: what the screen, the relationship check and the children say, in plain words. + function lead(f) { + var st = f.state, out = []; + var seq = (st.members || []).filter(function (m) { return m.sequenced; }).length; + out.push([seq + ' genome' + (seq === 1 ? '' : 's') + ' of this family are read together here. ', false]); + (st.couples || []).forEach(function (c) { + if (!c.screened) { out.push([c.a + ' and ' + c.b + ' are not screened as a couple yet. ', false]); return; } + if (c.shared) { out.push([c.a + ' and ' + c.b + ' both carry a change in ' + c.shared + ' gene' + (c.shared > 1 ? 's' : '') + ': chapter III gives the chance per pregnancy for each. ', true]); } + else { out.push([c.a + ' and ' + c.b + ' share no carrier gene in what the screen sees; ', false], ['it does not see everything. ', false]); } + if (c.x_linked) { out.push([c.x_linked + ' X-linked finding' + (c.x_linked > 1 ? 's' : '') + ' bear on sons. ', true]); } + }); + var flags = (f.relationships || []).filter(function (r) { return r.verdict === 'mismatch' || r.verdict === 'warn'; }).length; + var rels = f.relationships || []; + var checked = rels.filter(function (r) { return r.verdict && r.verdict !== 'not_checked'; }).length; + if (rels.length) { + out.push([flags ? 'A measured relationship differs from the family tree: read chapter II before anything else. ' + : checked === rels.length ? 'The measured relationships match the family tree. ' + : 'Some relationships could not be checked: chapter II says which. ', !!flags]); + } + if ((st.children || []).length) { + out.push([(st.children.length === 1 ? 'One child' : st.children.length + ' children') + ': chapter IV shows what each inherited from whom.', false]); + } + return out; + } function memberUrl(f, s) { return f.runs && f.runs[s] ? f.runs[s].url : null; } // The pedigree drawn from the layout the server gives: a sequenced person with a run here is a link. @@ -39,6 +89,9 @@ var shape = p.sex === 'male' ? U.svg('rect', { x: p.x - h, y: p.y - h, width: L.size, height: L.size, 'class': cls }) : p.sex === 'female' ? U.svg('circle', { cx: p.x, cy: p.y, r: h, 'class': cls }) : U.svg('polygon', { points: [p.x, p.y - h, p.x + h, p.y, p.x, p.y + h, p.x - h, p.y].join(' '), 'class': cls }); + var pc = p.sequenced ? colourOf(f, p.sample) : null; + if (pc) { shape.style.setProperty('stroke', pc); } + if (!p.sequenced) { shape.style.setProperty('stroke-dasharray', '4 3'); } g.appendChild(shape); var t = U.svg('text', { x: p.x, y: p.y + h + 17, 'text-anchor': 'middle', 'class': 'ped-name' }); t.textContent = p.sample; @@ -126,6 +179,16 @@ 'screen does not see. '; if (c.report) { add(p, el('a', { href: c.report }, 'What the screen does not see')); } add(s, p); + rows.filter(function (r) { return r.result === 'shared'; }).forEach(function (r) { + var box = el('div', { 'class': 'mini', 'data-shared': r.gene }); + add(box, el('h3', { 'class': 'h3' }, (r.condition || r.gene) + ' (' + r.gene + ')')); + var four = fourChildren(r.offspring_risk); + // A chance other than 1 in 4 means a partner with two changed copies: not the carrier pattern. + add(box, el('p', { 'class': 'soft' }, four ? 'Both carry a change in ' + r.gene + '. Carriers are not affected; a child who inherits both changes is.' + : 'Both carry a change in ' + r.gene + ', and at least one of them has two changed copies, which is not the carrier pattern. A child who inherits a changed copy from each parent is affected.')); + add(box, four || el('p', {}, 'Chance per pregnancy, as the screen gives it: ' + (r.offspring_risk || 'not given') + '.')); + add(s, box); + }); add(s, U.table({ columns: ['Result', 'Gene', 'Condition', 'Chance for a child', c.a, c.b, 'Why'], rows: rows.map(function (r) { var aFirst = r.partner_a === c.a; @@ -366,14 +429,26 @@ root.setAttribute('data-pgp-status', 'ready'); return; } + var cover = el('div', { 'class': 'famcover' }); + var cl = el('div', {}); + add(cl, el('div', { 'class': 'kicker' }, 'Family report \u00b7 in five chapters')); + add(cl, el('h1', { 'class': 'h-display' }, 'Family ' + f.state.name)); + var lp = el('p', { 'class': 'lede dropcap', id: 'family-lead' }); + lead(f).forEach(function (x) { add(lp, x[1] ? el('b', {}, x[0]) : x[0]); }); + add(cl, lp); + var toc = el('ol', { 'class': 'famtoc' }); + [['ch-ped', 'I', 'Pedigree and members'], ['ch-related', 'II', 'Who is related how'], ['ch-couples', 'III', 'Couple carrier screen'], + ['ch-children', 'IV', 'Each child: what they inherited'], ['ch-side-by-side', 'V', 'Side by side']].forEach(function (x) { + add(toc, add(el('li', {}), add(el('a', { href: '#' + x[0] }), el('span', { 'class': 'secnum' }, x[1]), el('span', {}, x[2])))); + }); + add(cover, cl, add(el('aside', {}), el('div', { 'class': 'fig-label' }, 'The chapters'), toc)); + add(page, cover); var top = el('div', { 'class': 'family-top' }); var ped = section('Pedigree', 'ped'); add(ped, pedigree(f), analyses(f)); add(top, ped, members(f)); add(page, top); - var grid = el('div', { 'class': 'family-grid' }); - add(grid, couples(f), related(f)); - add(page, grid, children(f), sideBySide(f)); + add(page, related(f), couples(f), children(f), sideBySide(f)); if ((f.state.plan || []).length || f.state.reports.length) { add(page, more(f)); } add(main, page); root.setAttribute('data-pgp-status', 'ready'); diff --git a/assets/live/family.html b/assets/live/family.html index ebe0d057..b685eca0 100644 --- a/assets/live/family.html +++ b/assets/live/family.html @@ -10,13 +10,14 @@ {{plugins}} -
- OwnGenome - +
+ OwnGenome + -
+

Loading the family.

+ diff --git a/assets/live/finding.html b/assets/live/finding.html new file mode 100644 index 00000000..e1907433 --- /dev/null +++ b/assets/live/finding.html @@ -0,0 +1,22 @@ + + + + + +{{run}}: one finding: OwnGenome + + + + + +
+ OwnGenome + + +
+
+

Loading the finding.

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

{{run}} loading

-

- -

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

{{run}}

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

How the run went

+

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

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

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

OwnGenome

+

OwnGenome

{{message}}

diff --git a/assets/live/person.js b/assets/live/person.js index 6a708403..23b134de 100644 --- a/assets/live/person.js +++ b/assets/live/person.js @@ -1,24 +1,28 @@ -// person.js: a person's results on their page (assets/live/index.html), from results.json -// (bin/live_results.py, served by bin/owngenome_live_results.py) and the run's states (live.js). -// The rail holds the report's seven chapter questions, "Look first" (every card worth a look) and -// "What this leaves out"; Progress is live.js's step map. The tab lives in the URL fragment only -// (#q/, #s/
, #progress), which the browser never sends, so no tab or search reaches -// a server log. A card shows its results once one of its steps is done in the current state, so live -// and replay draw the same way and a replay reveals the results as the run goes. +// person.js: a person's genome as a report, the Journal design (docs/design/journal/), on their page +// (assets/live/index.html), from results.json (bin/live_results.py, served by +// bin/owngenome_live_results.py) and the run's states (live.js). The page reads top to bottom: the lead +// paragraph built from the findings, the counts by level, the chromosome map of the findings, then ten +// numbered sections: 1 To act on, 2 Medicines, 3 For your family, 4 Worth a look, 5 Can I trust this?, +// 6 Origins, 7 Traits and scores, 8 Assessed, no finding, 9 What it cannot see, 10 Sources and files. +// A run still going is the same report being written: each section carries its stamp (written, being +// written, waiting) and the step timeline sits on top. Every card of results.json is on the page under +// its section; a card shows its results once one of its steps is done in the current state, so live +// and replay draw the same way. Links inside the page live in the fragment (#s/, #s3, #f/, +// #progress), which the browser never sends. Silence is never an all-clear: a section with no finding +// says what its steps looked at and what they do not see. (function () { 'use strict'; if (!window.PGP || !window.PGPUI) { return; } var U = window.PGPUI, el = U.el, add = U.add; var root = document.documentElement; var replay = root.getAttribute('data-mode') === 'replay'; - var results = null, state = null, fetchedFor = null, current = null, chosen = false, drawnKey = null; - var rail = document.getElementById('rail'); + var results = null, state = null, family = null, familyFor = null, fetchedFor = null, drawnKey = null; + // 'none': in no family; 'pending' or 'failed': in one whose roles are not known (yet); 'known'. + var familyStatus = 'none'; + var shown = {}; // veiled findings the reader opened, by id var panel = document.getElementById('results'); - var progress = document.getElementById('progress'); - var SHORT = { quality: 'Trust', clinical: 'Doctor', carrier: 'Child', medicines: 'Medicines', ancestry: 'Ancestors', - traits: 'Traits', other: 'Other' }; - var LOOK_EMPTY = 'Nothing on this list. That is information, not a clean bill: each chapter’s caveats and ' + - '“What this leaves out” still apply.'; + var rail = document.getElementById('rail'); + var NOT_ALL_CLEAR = 'No finding in what these steps assessed.'; function stepsBy() { var by = {}; @@ -29,100 +33,92 @@ return ((state && state.steps) || []).filter(function (s) { return s.state === 'done'; }) .map(function (s) { return s.step; }).join(','); } - function anyDone() { return doneSet() !== ''; } - // What a card can show at this moment: 'show', 'waiting' (a step still to come), 'notrun' (none planned) - // or 'empty' (its steps ended and wrote nothing for it). + function running() { return !!(state && state.run && state.run.state === 'running'); } + // What a card can show at this moment: 'show', 'waiting' (a step still to come), 'running' (one of its + // steps runs now), 'failed', 'notrun' (none planned) or 'empty' (its steps ended and wrote nothing). function gate(c) { var by = stepsBy(), steps = c.steps || []; var known = steps.filter(function (s) { return by[s]; }); if (known.some(function (s) { return by[s].state === 'done'; })) { return c.state === 'missing' ? 'empty' : 'show'; } - if (known.some(function (s) { return ['waiting', 'running'].indexOf(by[s].state) >= 0; })) { return 'waiting'; } + if (known.some(function (s) { return by[s].state === 'running'; })) { return 'running'; } + if (known.some(function (s) { return by[s].state === 'waiting'; })) { return 'waiting'; } if (known.some(function (s) { return by[s].state === 'failed'; })) { return 'failed'; } if (!known.length && c.state !== 'missing' && !replay) { return 'show'; } return 'notrun'; } - function waitingText(c) { + function pending(g) { return g === 'waiting' || g === 'running'; } + function stepWords(c) { var by = stepsBy(); var s = (c.steps || []).filter(function (x) { return by[x] && by[x].state !== 'done'; })[0] || (c.steps || [])[0]; - return 'Waiting for step ' + s + (by[s] ? ' (' + by[s].label + ')' : '') + '.'; + return s ? (by[s] ? by[s].label : 'step ' + s) : 'its step'; } function cards() { var out = []; - ((results && results.chapters) || []).forEach(function (ch) { - ch.sections.forEach(function (c) { out.push({ ch: ch, c: c }); }); - }); + ((results && results.chapters) || []).forEach(function (ch) { ch.sections.forEach(function (c) { out.push(c); }); }); return out; } + function cardById(id) { return cards().filter(function (c) { return c.id === id; })[0]; } + function findingById(id) { return ((results && results.findings) || []).filter(function (f) { return f.id === id; })[0]; } + function findingsNow() { + return ((results && results.findings) || []).filter(function (f) { var c = cardById(f.card); return c && gate(c) === 'show'; }); + } + function sectionsList() { return (results && results.journal) || []; } function attentionNow(c) { return c.attention && gate(c) === 'show'; } + function personName() { return (results && results.person && results.person.sample) || root.getAttribute('data-run') || ''; } - // --- the 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; + // --- the family this person is in: family.json of the first family the family plugin names ------- + function familyUrl() { + var fam = state && state.plugins && state.plugins.family; + var f = fam && fam.kind === 'member' && (fam.families || [])[0]; + return f ? f : null; + } + function loadFamily() { + var f = familyUrl(); + var key = f ? f.url : null; + if (key === familyFor) { return; } + familyFor = key; + if (!f) { family = null; familyStatus = 'none'; return; } + familyStatus = 'pending'; + U.getJSON(f.url + 'family.json').then(function (d) { + if (familyFor !== key) { return; } + family = d && d.state ? { name: f.name, url: f.url, data: d } : null; + familyStatus = family ? 'known' : 'failed'; + redraw(true); + }).catch(function () { + if (familyFor !== key) { return; } // a late answer for a family this page no longer shows + family = null; + familyStatus = 'failed'; + redraw(true); }); - 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'); + function me() { + var ms = (family && family.data.state && family.data.state.members) || []; + return ms.filter(function (m) { return m.sample === personName(); })[0] || null; + } + // A child of the family: a member whose father or mother is listed. Results to act on about a child + // stay closed until someone opens them on purpose. + function isChild() { var m = me(); return !!(m && (m.father || m.mother)); } + // Results to act on stay closed for a child, and for anyone in a family whose roles are not known yet: + // the page cannot tell a child apart until family.json has answered. + // A family that answers without this person cannot say they are not a child either. + function closedPage() { return isChild() || familyStatus === 'pending' || familyStatus === 'failed' || (familyStatus === 'known' && !me()); } + function memberUrl(s) { + var r = family && family.data.runs && family.data.runs[s]; + return r && r.url ? r.url : null; + } + function role() { + var m = me(); + if (!m) { return null; } + var ms = family.data.state.members; + var kids = ms.filter(function (x) { return x.father === m.sample || x.mother === m.sample; }); + var bits = []; + if (m.father || m.mother) { + bits.push(({ male: 'son', female: 'daughter' }[m.sex] || 'child') + ' of ' + [m.father, m.mother].filter(Boolean).join(' and ')); } - tab('progress', 'Progress', 0, '#progress'); + if (kids.length) { bits.push(({ male: 'father', female: 'mother' }[m.sex] || 'parent') + ' of ' + kids.map(function (k) { return k.sample; }).join(', ')); } + if (m.partner) { bits.push('partner of ' + m.partner); } + return bits.join(', '); } - U.tablist(rail, function (t) { go(t.getAttribute('href')); }); // --- widgets: a card's numbers drawn --------------------------------------------------------------- // HTML placed in percent of a track (custom properties, never a style attribute), so the text stays @@ -292,45 +288,64 @@ try { return fn(w); } catch (e) { return el('p', { 'class': 'dim' }, 'This drawing could not be made; the table below has the numbers.'); } } - // --- a card ----------------------------------------------------------------------------------- - function badge(text, cls, symbol) { - var b = el('span', { 'class': 'chip ' + cls }); - if (symbol) { add(b, el('span', { 'class': 'sym', 'aria-hidden': 'true' }, symbol)); } - add(b, text); - return b; - } - function recheckBadges(c) { + // --- a card: one section of the report, with its numbers, its rows, its figure and its caveat ------- + function recheckWords(c) { var r = state && state.plugins && state.plugins.recheck; var out = []; (c.steps || []).forEach(function (s) { - ((r && r.steps && r.steps[s]) || []).forEach(function (t) { out.push(badge(t, 'chip-recheck', '↻')); }); + ((r && r.steps && r.steps[s]) || []).forEach(function (t) { if (out.indexOf(t) < 0) { out.push(t); } }); }); return out; } - function card(c, ch) { + function chip(text, cls, symbol) { + var b = el('span', { 'class': 'chip ' + cls }); + if (symbol) { add(b, el('span', { 'class': 'sym', 'aria-hidden': 'true' }, symbol)); } + add(b, text); + return b; + } + // The findings of a card that a child's page keeps closed: while one is closed the card's own rows, + // numbers and drawing stay closed too, or its table would name the gene and variant under the veil. + function veiledOf(c) { return (c.findings || []).map(findingById).filter(function (f) { return f && veiled(f); }); } + + function card(c) { var g = gate(c); - var cls = 'card result g-' + g + (attentionNow(c) ? ' attn' : ''); - var art = el('article', { 'class': cls, id: 'card-' + c.id, 'data-section': c.id, 'data-step': (c.steps || []).join(' '), - 'data-state': c.state, tabindex: '-1', 'aria-labelledby': 'h-' + c.id }); + var closedFs = g === 'show' ? veiledOf(c) : []; + var art = el('article', { 'class': 'card result g-' + g + (attentionNow(c) ? ' attn' : ''), id: 'card-' + c.id, + 'data-section': c.id, 'data-step': (c.steps || []).join(' '), 'data-state': c.state, 'data-level': c.level || '', + tabindex: '-1', 'aria-labelledby': 'h-' + c.id }); var head = el('header', { 'class': 'card-head' }); var h = el('h3', { id: 'h-' + c.id }); add(h, el('a', { href: '#s/' + c.id, 'class': 'self' }, c.title)); add(head, h); var chips = el('div', { 'class': 'chips' }); if (g === 'show') { - add(chips, attentionNow(c) ? badge('worth a look', 'chip-attn', '▲') : badge('for information', 'chip-info', '○')); + // The card's level, on the one scale: a card the report flags is "worth a look" at least. + var lv = c.level || (attentionNow(c) ? 'review' : 'info'); + add(chips, U.level(lv, attentionNow(c) && lv === 'info' ? 'Worth a look' : undefined)); } - recheckBadges(c).forEach(function (b) { add(chips, b); }); + recheckWords(c).forEach(function (t) { add(chips, chip(t, 'chip-recheck', '↻')); }); add(head, chips); add(art, head); - if (g === 'waiting') { - add(art, el('p', { 'class': 'state-text' }, waitingText(c))); + if (pending(g)) { + add(art, el('p', { 'class': 'state-text' }, (g === 'running' ? 'Being written: ' : 'Waiting for ') + stepWords(c) + + (g === 'running' ? ' is running now.' : '.'))); } else if (g === 'notrun') { - add(art, el('p', { 'class': 'state-text' }, 'Not run in this run (step ' + (c.steps || []).join(', ') + ').')); + add(art, el('p', { 'class': 'notassessed' }, 'Not assessed: no step of this run made this section (step ' + (c.steps || []).join(', ') + ').')); } else if (g === 'failed') { - add(art, el('p', { 'class': 'state-text' }, 'Step ' + (c.steps || []).join(', ') + ' failed: the Progress tab says why.')); + add(art, add(el('p', { 'class': 'notassessed' }), el('b', {}, 'Not assessed. '), + 'Step ' + (c.steps || []).join(', ') + ' failed: the timeline below says why, and a rerun of the step fills this section.')); } else if (g === 'empty') { - add(art, el('p', { 'class': 'state-text' }, 'Its step ended without a result for this section.')); + add(art, el('p', { 'class': 'notassessed' }, 'Not assessed: its step ended without a result for this section.')); + } else if (closedFs.length) { + var v = el('div', { 'class': 'veil' }); + // One button opens one result: a card with two results to act on asks twice. + add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), + (closedFs.length === 1 ? 'This section holds a result' : 'This section holds ' + closedFs.length + ' results') + + ' to act on in adult life. Its rows stay closed until ' + (closedFs.length === 1 ? 'it is' : 'each is') + ' opened.')); + var vb = el('button', { type: 'button', 'class': 'btn' }, 'Show the result'); + vb.addEventListener('click', function () { shown[closedFs[0].id] = true; redraw(true); var x = document.getElementById('f-' + closedFs[0].id) || document.getElementById('card-' + c.id); if (x) { x.scrollIntoView({ block: 'start' }); } }); + add(v, vb); + add(art, v); } else { (c.notes || []).forEach(function (n) { add(art, el('p', { 'class': 'note-stale' }, n)); }); if (c.headline) { add(art, el('p', { 'class': 'headline' }, c.headline)); } @@ -348,6 +363,7 @@ add(art, ul); } if (c.hidden) { + // APOE and the like: behind their warning, opened on purpose. var d = el('details', { 'class': 'behind' }); add(d, el('summary', {}, c.hidden.summary), el('p', {}, c.hidden.text)); add(art, d); @@ -374,119 +390,493 @@ return art; } - // --- the views -------------------------------------------------------------------------------- - function heading(text, sub) { - var h = el('header', { 'class': 'view-head' }); - add(h, el('h2', { 'class': 'question' }, text)); - if (sub) { add(h, el('p', { 'class': 'dim' }, sub)); } - return h; + // --- a finding in its section ------------------------------------------------------------------------ + function findingHref(f) { return 'finding/' + encodeURIComponent(f.id) + '/'; } + function newAt(f) { + if (!running()) { return null; } + var by = stepsBy(), t = null; + (f.steps || []).forEach(function (s) { if (by[s] && by[s].ended_utc) { t = by[s].ended_utc; } }); + return t; } - function lookFirst() { - var box = el('div', { id: 'look-first', 'class': 'view' }); - add(box, heading('Look at these first', 'The cards the report itself flags, in chapter order. A card not listed is for information; its caveats still apply.')); - var items = cards().filter(function (x) { return attentionNow(x.c); }); - if (!items.length) { - add(box, el('p', { 'class': 'empty look-empty' }, LOOK_EMPTY)); - } else { - var ol = el('ol', { 'class': 'look-list' }); - items.forEach(function (x) { - var li = el('li', {}); - var a = el('a', { href: '#s/' + x.c.id }); - add(a, el('span', { 'class': 'look-ch' }, SHORT[x.ch.key] || x.ch.title), el('span', { 'class': 'look-title' }, x.c.title)); - add(li, a); - if (x.c.headline) { add(li, el('p', { 'class': 'look-head' }, x.c.headline)); } - add(ol, li); - }); - add(box, ol); + function note(head, body, cls) { + var n = el('div', { 'class': 'note' + (cls ? ' ' + cls : '') }); + add(n, el('span', { 'class': 'nh' }, head)); + add(n, body); + return n; + } + function evidence(rows) { + if (!rows || !rows.length) { return null; } + var dl = el('dl', {}); + rows.forEach(function (r) { add(dl, el('dt', {}, r[0]), el('dd', {}, r[1])); }); + return dl; + } + function caveatNote(list) { + if (!list || !list.length) { return null; } + var box = el('div', {}); + var lead = U.firstSentence(list[0]); + add(box, el('span', {}, lead + ' ')); + var d = el('details', {}); + add(d, el('summary', { 'class': 'toggle' }, 'More')); + list.forEach(function (t) { add(d, el('p', {}, t)); }); + add(box, d); + return note('Caveat', box, 'caveat-note'); + } + function veiled(f) { return closedPage() && f.level === 'act' && !shown[f.id]; } + function findingArticle(f, big) { + var row = el('div', { 'class': 'row' }); + var art = el('div', { 'class': 'finding', id: 'f-' + f.id, 'data-finding': f.id, 'data-level': f.level }); + var at = newAt(f); + if (at) { art.classList.add('new'); add(art, el('span', { 'class': 'newband' }, 'New at ' + U.hm(at))); add(art, el('br', {})); } + add(art, U.findingLevel(f.level, f.level_words)); + if (veiled(f)) { + var v = el('div', { 'class': 'veil' }); + add(v, add(el('div', { 'class': 'vt' }), el('b', {}, 'A result about a child. '), + 'A result to act on in adult life is usually not looked at before the child can choose. It stays closed until you open it.')); + var b = el('button', { type: 'button', 'class': 'btn' }, 'Show the result'); + b.addEventListener('click', function () { shown[f.id] = true; redraw(true); var x = document.getElementById('f-' + f.id); if (x) { x.scrollIntoView({ block: 'start' }); } }); + add(v, b); + add(art, v); + add(row, art, el('aside', {})); + return row; } - var waiting = cards().filter(function (x) { return gate(x.c) === 'waiting'; }).length; - if (waiting) { add(box, el('p', { 'class': 'dim' }, (waiting === 1 ? '1 card is' : waiting + ' cards are') + ' still waiting for its step.')); } - return box; + var h = el('h3', { 'class': big ? 'h1' : 'h3' }); + add(h, el('a', { href: findingHref(f) }, f.title)); + add(art, h); + if (f.detail) { add(art, el('div', { 'class': 'gene' }, f.detail)); } + add(art, el('p', { 'class': 'body-serif' }, f.text)); + if (big && f.plan && f.plan.length) { + add(art, el('div', { 'class': 'todo-h' }, 'What to do')); + var ol = el('ol', { 'class': 'todo' }); + f.plan.forEach(function (t) { add(ol, el('li', {}, t)); }); + add(art, ol); + } + add(art, el('a', { 'class': 'readmore', href: findingHref(f) }, 'The full evidence and what to do →')); + var side = el('aside', {}); + var ev = evidence(f.evidence); + if (ev) { add(side, note('Evidence', ev)); } + add(side, caveatNote(f.caveat)); + var rel = (f.related || []).map(findingById).filter(function (r) { return r && !veiled(r); }); + if (rel.length) { + var box = el('div', {}); + rel.slice(0, 4).forEach(function (r, i) { if (i) { add(box, el('br', {})); } add(box, el('a', { href: '#f/' + r.id }, r.title)); }); + add(side, note('Linked', box)); + } + add(row, art, side); + return row; } - function chapter(ch) { - var box = el('div', { 'class': 'view chapter', 'data-chapter': ch.key }); - add(box, heading(ch.question, ch.title)); - var grid = el('div', { 'class': 'cards' }); - ch.sections.forEach(function (c) { add(grid, card(c, ch)); }); - add(box, grid); - return box; + function findingLine(f) { + var li = el('li', { id: 'f-' + f.id, 'data-finding': f.id, 'data-level': f.level }); + add(li, el('a', { 'class': 't', href: findingHref(f) }, f.title), U.findingLevel(f.level, f.level_words)); + if (f.detail) { add(li, el('span', { 'class': 'gene' }, f.detail)); } + add(li, el('span', { 'class': 'x' }, f.text)); + return li; } - function leaves() { - var box = el('div', { 'class': 'view' }); - add(box, heading('What this leaves out', 'What no step looks at, and the steps that did not run.')); - function list(title, items) { - var s = el('section', { 'class': 'card' }); - add(s, el('h3', {}, title)); - var ul = el('ul', { 'class': 'lines' }); - (items.length ? items : ['None.']).forEach(function (t) { add(ul, el('li', {}, t)); }); - add(s, ul); - add(box, s); + + // --- the sections --------------------------------------------------------------------------------- + // A section's stamp: written (with the time its last step ended), being written (a step runs), waiting. + // The chapters whose steps can still add a finding to sections 1 to 4 while they run. + var FEEDS = { clinical: true, carrier: true, medicines: true }; + function feeding(sec) { + return sec.n <= 4 ? cards().filter(function (c) { return FEEDS[c.chapter] && sec.cards.indexOf(c.id) < 0; }) : []; + } + function stamp(sec) { + var cs = sec.cards.map(cardById).filter(Boolean).concat(feeding(sec)); + if (!cs.length) { return { k: 'written', t: 'Written' }; } + var gs = cs.map(gate); + if (gs.some(function (g) { return g === 'running'; })) { return { k: 'writing', t: 'Being written' }; } + if (gs.some(function (g) { return g === 'waiting'; })) { + return gs.some(function (g) { return g === 'show'; }) ? { k: 'writing', t: 'Half written' } : { k: 'waiting', t: 'Waiting' }; } - list('No step looks at these', (results && results.not_assessed) || []); - list('Steps that did not run', (results && results.not_run) || []); + var by = stepsBy(), t = null; + cs.forEach(function (c) { (c.steps || []).forEach(function (s) { if (by[s] && by[s].ended_utc && (!t || by[s].ended_utc > t)) { t = by[s].ended_utc; } }); }); + return { k: 'written', t: 'Written' + (t && running() ? ' ' + U.hm(t) : '') }; + } + function sectionHead(sec) { + var head = el('div', { 'class': 'sechead' }); + add(head, el('span', { 'class': 'secnum' }, String(sec.n)), el('h2', { 'class': 'h2' }, sec.title)); + if (running()) { var st = stamp(sec); add(head, el('span', { 'class': 'stamp ' + st.k }, st.t)); } + return head; + } + function waitBlock(sec) { + var cs = sec.cards.map(cardById).filter(function (c) { return c && pending(gate(c)); }); + if (!cs.length) { return null; } + var box = el('div', {}); + add(box, add(el('div', { 'class': 'proof', 'aria-hidden': 'true' }), el('i', {}), el('i', {}), el('i', {}))); + var names = cs.map(function (c) { return c.title + (gate(c) === 'running' ? ' (running)' : ''); }); + add(box, el('p', { 'class': 'waitnote' }, 'This section stays open until its steps finish: ' + names.join(', ') + + '. Until then this page says nothing either way.')); return box; } - function show(tab) { - current = tab; - var isProgress = tab === 'progress'; - progress.hidden = !isProgress; - panel.hidden = isProgress; - root.setAttribute('data-pgp-tab', tab); - if (isProgress) { return; } + // What the clinical steps of section 8 looked at, named in an empty section 1 to 4. + function assessedNames() { + var s8 = sectionsList().filter(function (s) { return s.n === 8; })[0]; + return s8 ? s8.cards.map(cardById).filter(function (c) { return c && gate(c) === 'show'; }).map(function (c) { return c.title; }) : []; + } + function sectionBody(sec) { + var body = el('div', {}); + var fs = sec.findings.map(findingById).filter(function (f) { var c = f && cardById(f.card); return c && gate(c) === 'show'; }); + var cs = sec.cards.map(cardById).filter(Boolean); + var big = sec.n === 1 || (sec.n === 4 && fs.length <= 2); + if (sec.n <= 4 || sec.n === 5) { + if (fs.length) { + if (big || sec.n === 1 || fs.length <= 3) { fs.forEach(function (f) { add(body, findingArticle(f, big)); }); } + else { var ul = el('ul', { 'class': 'list-findings' }); fs.forEach(function (f) { add(ul, findingLine(f)); }); add(body, ul); } + } else if (sec.n !== 5) { + var w = waitBlock(sec); + var pend = feeding(sec).some(function (c) { return pending(gate(c)); }); + if (w) { add(body, w); } else if (pend) { + add(body, el('p', { 'class': 'waitnote' }, 'This section stays open until the steps that can add a finding here finish. Until then this page says nothing either way.')); + } else { + var e = el('p', { 'class': 'empty-sec' }, NOT_ALL_CLEAR); + add(body, e); + var names = sec.n === 1 || sec.n === 4 ? assessedNames() : cs.filter(function (c) { return gate(c) === 'show'; }).map(function (c) { return c.title; }); + if (names.length) { add(body, el('p', { 'class': 'soft' }, 'Assessed: ' + names.join(', ') + '. Section 8 lists what each looked at, section 9 what no step sees.')); } + } + } + } + if (sec.n === 8) { + var nf = el('ul', { 'class': 'nf' }); + cs.forEach(function (c) { + var li = el('li', {}); + var d = el('details', { 'data-card': c.id }); + var sum = el('summary', {}); + var g = gate(c); + var what = g === 'show' && veiledOf(c).length ? 'A result about a child, closed until opened.' + : g === 'show' ? (c.headline || (c.facts && c.facts[0] ? c.facts[0][0] + ': ' + c.facts[0][1] : NOT_ALL_CLEAR)) + : pending(g) ? 'Waiting for ' + stepWords(c) + '.' : g === 'failed' ? 'Not assessed: its step failed.' : 'Not assessed in this run.'; + var w2 = el('span', { 'class': 'what' }, what); + if (c.caveat && c.caveat.length) { add(w2, el('span', { 'class': 'cav' }, 'Does not see: ' + U.firstSentence(c.caveat[0]))); } + add(sum, el('span', { 'class': 'nfname' }, c.title), w2); + add(d, sum, card(c)); + add(li, d); + add(nf, li); + }); + add(body, nf); + return body; + } + if (sec.n === 9) { + var lists = [['No step looks at these', (results && results.not_assessed) || []], ['Steps that did not run', (results && results.not_run) || []]]; + lists.forEach(function (x) { + add(body, el('h3', { 'class': 'h3' }, x[0])); + var ul2 = el('ul', { 'class': 'cols2' }); + (x[1].length ? x[1] : ['None listed.']).forEach(function (t) { add(ul2, el('li', {}, t)); }); + add(body, ul2); + }); + return body; + } + if (sec.n === 10) { + var dbs = (results && results.databases) || {}; + var keys = Object.keys(dbs); + if (keys.length) { + var t = el('table', { 'class': 'sources' }); + add(t, add(el('tr', {}), el('th', {}, 'Source'), el('th', {}, 'Version or date'))); + keys.forEach(function (k) { add(t, add(el('tr', {}), el('td', {}, k.replace(/_/g, ' ')), el('td', {}, dbs[k]))); }); + add(body, t); + } + var rc = state && state.plugins && state.plugins.recheck; + if (rc && rc.banner) { add(body, el('p', { 'class': 'note-stale' }, rc.banner)); } + var fb = el('div', { 'class': 'filebtns' }); + add(fb, el('a', { 'class': 'btn', href: 'report.html' }, 'The full report (HTML)')); + add(fb, el('a', { 'class': 'btn ghost', href: 'viewer/' }, 'Genome viewer')); + add(body, fb); + } + cs.forEach(function (c) { add(body, card(c)); }); + return body; + } + function drawSections() { panel.textContent = ''; if (results && results.note && !(results.chapters || []).length) { add(panel, el('p', { 'class': 'empty' }, results.note)); return; } - if (tab === 'look') { add(panel, lookFirst()); } else if (tab === 'leaves') { add(panel, leaves()); } else { - var ch = ((results && results.chapters) || []).filter(function (c) { return c.key === tab; })[0]; - if (ch) { add(panel, chapter(ch)); } - } + sectionsList().forEach(function (sec) { + var s = el('section', { 'class': 'sec', id: 's' + sec.n, 'data-journal': sec.key, 'aria-labelledby': 'sh' + sec.n }); + var head = sectionHead(sec); + head.querySelector('h2').id = 'sh' + sec.n; + add(s, head, el('div', { 'class': 'secq' }, sec.question)); + add(s, sectionBody(sec)); + add(panel, s); + }); root.setAttribute('data-pgp-results', String(panel.querySelectorAll('article.result').length)); + root.setAttribute('data-pgp-findings', String(panel.querySelectorAll('[data-finding]').length)); document.dispatchEvent(new CustomEvent('pgp:cards')); } - // --- the header ------------------------------------------------------------------------------- + // --- the lead, the counts by level, the chromosome map --------------------------------------------- + function counts() { + var n = { act: 0, medicine: 0, review: 0, carrier: 0, trust: 0, info: 0 }; + findingsNow().forEach(function (f) { n[f.level] += 1; }); + n.info = cards().filter(function (c) { return gate(c) === 'show' && c.level === 'info'; }).length; + return n; + } + function drawLead() { + var slot = document.getElementById('lead-slot'); + slot.textContent = ''; + if (!results || !(results.chapters || []).length) { return; } + var row = el('div', { 'class': 'row' }); + var main = el('div', {}); + var p = el('p', { 'class': 'lede dropcap', id: 'lead' }); + var closed = (results.findings || []).some(veiled); + var parts = (results.lead || []).map(function (x) { + return x[2] === 'act' && closed ? [': closed on this page until opened on purpose, in section 1. ', false] : x; + }); + if (replay && running()) { + // A replay holds the results of the finished run: before its end the lead would name findings the + // sections do not show yet. + parts = [['This report is being written. ', true], ['The lead paragraph is written when the run ends; the sections below fill in as their steps finish.', false]]; + } else if (running()) { + parts = [['This report is being written. ', true], ['What follows is what has been found so far; a section with steps still to run says so. ', false]] + .concat(parts.filter(function (x) { return !/^Mean depth/.test(x[0]); })); + } + parts.forEach(function (x) { add(p, x[1] ? el('b', {}, x[0]) : x[0]); }); + add(main, p); + var n = counts(); + var fs = findingsNow(); + var led = el('div', { 'class': 'ledger', id: 'ledger' }); + [['act', 1], ['medicine', 2], ['review', 4], ['carrier', 3], ['info', 6]].forEach(function (x) { + var a = el('a', { href: '#s' + x[1], 'data-level': x[0] }); + add(a, U.level(x[0], { act: 'Take to a doctor', medicine: 'Prescriber', review: 'Worth a look', carrier: 'Family planning', info: 'Information' }[x[0]])); + add(a, el('span', { 'class': 'n' }, String(n[x[0]]))); + var words = x[0] === 'info' ? 'Origins, traits, scores' : x[0] === 'act' && closed && n.act ? 'Closed until opened' : + fs.filter(function (f) { return f.level === x[0]; }).slice(0, 2).map(function (f) { return f.gene || f.title; }).join(', ') || 'None in what was assessed'; + add(a, el('span', { 'class': 'w' }, words)); + add(led, a); + }); + add(main, led); + var trust = el('div', { 'class': 'trustline' }); + var problems = fs.filter(function (f) { return f.level === 'trust'; }); + add(trust, U.level('trust', 'Data quality', problems.length ? 'problem' : '')); + var qc = cardById('qc'); + if (qc && gate(qc) === 'show') { + (qc.facts || []).filter(function (f) { return /depth|contamination|sex from/i.test(f[0]); }).slice(0, 3).forEach(function (f, i) { + if (i) { add(trust, el('span', { 'class': 'sep' }, '·')); } + add(trust, el('span', { title: f[0] + ': ' + f[1] }, String(f[0]).replace(/\s*\([^)]*\)/g, '') + ': ' + String(f[1]).split(/[.;(]\s/)[0].replace(/[.;]$/, '').trim())); + }); + } else { add(trust, el('span', {}, 'Waiting for the quality steps.')); } + problems.forEach(function (f) { add(trust, el('span', { 'class': 'sep' }, '·'), el('a', { href: '#f/' + f.id }, f.title)); }); + add(trust, el('span', { 'class': 'sep' }, '·'), el('a', { href: '#s5' }, 'Can I trust this?')); + add(main, trust); + var side = el('aside', {}); + add(side, note('How to read this', el('span', {}, 'Sections run from what matters most to what is only interesting. A section with nothing to report still says what it looked at and what it cannot see.'))); + add(side, note('A research pipeline', el('span', {}, 'Not a clinical test: anything to act on is confirmed by a certified laboratory first.'))); + add(row, main, side); + add(slot, row); + // Figure 1: where the findings sit. + var marks = []; + fs.forEach(function (f) { + var l = U.locus(f.viewer); + if (l && !veiled(f)) { marks.push({ chr: l.chr, mb: l.mb, lv: f.level, label: f.gene || '', tip: f.title, href: findingHref(f) }); } + }); + var fig = el('figure', { 'class': 'row fig1' }); + var inner = el('div', {}); + add(inner, el('div', { 'class': 'fig-label' }, 'Figure 1 · Where the findings sit')); + var k = el('div', { 'class': 'kary', id: 'kary' }); + add(k, U.karyogram(marks)); + add(inner, k); + add(inner, el('figcaption', {}, marks.length ? 'Each mark is one finding with a position, on its chromosome. Shape and colour give the level, as in the row above; every finding is also listed in its section.' + : 'No finding with a position yet. The findings without one (medicine genes, repeat counts, copy numbers) are listed in their sections.')); + add(fig, inner, el('aside', {})); + add(slot, fig); + } + + // --- the table of contents, the phone tab bar, the cover ------------------------------------------ + function drawToc() { + rail.textContent = ''; + if (!sectionsList().length) { return; } + add(rail, el('div', { 'class': 'group' }, running() ? 'The report, section by section' : 'In this report')); + var ol = el('ol', {}); + var n = counts(); + var lvOf = { 1: 'act', 2: 'medicine', 3: 'carrier', 4: 'review' }; + sectionsList().forEach(function (sec) { + var a = el('a', { href: '#s' + sec.n, 'data-toc': sec.n }); + add(a, el('span', { 'class': 'tn' }, String(sec.n)), el('span', {}, sec.title)); + var lv = lvOf[sec.n]; + if (lv && n[lv]) { add(a, U.level(lv, String(n[lv]), 'tc')); } + else if (sec.n === 5) { add(a, U.level('trust', '', 'tc' + (n.trust ? ' problem' : ''))); } + else if (sec.n === 8) { add(a, el('span', { 'class': 'tc muted' }, String(sec.cards.length))); } + if (running()) { var st = stamp(sec); add(a, el('span', { 'class': 'tsub ' + st.k }, st.t.toLowerCase())); } + add(ol, add(el('li', {}), a)); + }); + add(rail, ol); + add(rail, el('div', { 'class': 'group' }, 'How the run went')); + add(rail, add(el('div', { 'class': 'people' }), add(el('a', { href: '#progress' }), el('span', {}, 'Step timeline'), el('span', { 'class': 'muted' }, '→')))); + if (family) { + add(rail, el('div', { 'class': 'group' }, 'The family')); + var ppl = el('div', { 'class': 'people' }); + add(ppl, add(el('a', { href: family.url }), el('span', {}, 'Family report'), el('span', { 'class': 'muted' }, '→'))); + (family.data.state.members || []).forEach(function (m) { + if (m.sample === personName()) { return; } + var u = memberUrl(m.sample); + var a = el(u ? 'a' : 'span', u ? { href: u } : { 'class': 'muted' }); + add(a, el('span', { 'class': 'pc' + (m.sequenced ? '' : ' nogenome') }, m.sample)); + var r = family.data.runs && family.data.runs[m.sample]; + if (r && r.attention) { add(a, U.level('review', String(r.attention), 'tc')); } + add(ppl, a); + }); + add(rail, ppl); + } + } + function drawTabbar() { + var bar = document.getElementById('tabbar'); + if (!bar) { return; } + bar.textContent = ''; + var n = counts(); + [[1, 'To act', 'act'], [2, 'Medicines', 'medicine'], [3, 'Family', 'carrier'], [5, 'Trust', 'trust'], [8, 'More', null]].forEach(function (x) { + var a = el('a', { href: '#s' + x[0] }); + add(a, el('span', { 'class': 'ti' }, String(x[0]))); + add(a, el('span', {}, x[1] + (x[2] && n[x[2]] ? ' · ' + n[x[2]] : ''))); + add(bar, a); + }); + } function header() { var p = (results && results.person) || {}; - var slot = document.getElementById('avatar-slot'); var planned = ((state && state.steps) || []).filter(function (s) { return s.state !== 'not planned'; }); var done = planned.filter(function (s) { return s.state === 'done'; }).length; + var slot = document.getElementById('avatar-slot'); slot.textContent = ''; add(slot, U.avatar(p.inferred_sex || (state && state.run && state.run.declared_sex), true, planned.length ? done / planned.length : 0)); + var run = (state && state.run) || {}; + var kicker = document.getElementById('kicker'); + kicker.textContent = (running() ? 'Being written · ' : 'Personal genome report') + (family ? (running() ? '' : ' · ') + 'Family ' + family.name : ''); var line = document.getElementById('person-line'); line.textContent = ''; var bits = []; - if (p.inferred_sex) { bits.push('Sex from the reads: ' + p.inferred_sex + (p.declared_sex ? ', declared ' + p.declared_sex : '')); } - if (bits.length) { add(line, bits.join('. ') + '. '); } - var fam = state && state.plugins && state.plugins.family; + if (p.inferred_sex || p.declared_sex) { + bits.push(el('span', {}, (p.inferred_sex ? p.inferred_sex + ' from the reads' : 'sex not measured') + + (p.declared_sex ? (p.declared_sex === p.inferred_sex ? ', as declared' : ', declared ' + p.declared_sex) : ''))); + } + var r = role(); + if (r) { bits.push(el('span', {}, r)); } + if (run.state === 'finished') { + var hh = U.hours(run.started_utc, run.finished_utc); + bits.push(el('span', {}, 'finished' + (run.finished_utc ? ' ' + U.dm(run.finished_utc) : '') + (hh ? ' after ' + hh.toFixed(1) + ' h' : ''))); + } else if (running()) { + bits.push(el('span', {}, done + ' of ' + planned.length + ' steps done')); + } + bits.forEach(function (b) { add(line, b); }); + // Breadcrumb: people / family / person. var crumbs = document.getElementById('crumbs'); var name = document.getElementById('run-name'); + var home = document.getElementById('home-crumb'); crumbs.textContent = ''; - ((fam && fam.kind === 'member' && fam.families) || []).forEach(function (f, i) { - if (i === 0) { add(line, 'In family '); } else { add(line, ', '); } - add(line, el('a', { href: f.url }, f.name)); - add(crumbs, el('a', { href: f.url }, f.name), el('span', { 'class': 'sep', 'aria-hidden': 'true' }, '/')); + add(crumbs, home, el('span', { 'class': 'sep', 'aria-hidden': 'true' }, '/')); + var fam = state && state.plugins && state.plugins.family; + ((fam && fam.kind === 'member' && fam.families) || []).forEach(function (f) { + add(crumbs, el('a', { href: f.url }, 'Family ' + f.name), el('span', { 'class': 'sep', 'aria-hidden': 'true' }, '/')); }); add(crumbs, name); - // The strip: one cell per planned step, filled when done, striped while it runs. + // The strip under the timeline: one cell per planned step. var strip = document.getElementById('strip'); strip.textContent = ''; - planned.forEach(function (s) { - add(strip, el('span', { 'class': 'cell st-' + s.state.replace(/\s+/g, '-'), title: s.step + ' ' + s.label + ': ' + s.state })); - }); - var txt = document.getElementById('strip-text'); - var running = planned.filter(function (s) { return s.state === 'running'; }).length; + planned.forEach(function (s) { add(strip, el('span', { 'class': 'cell st-' + s.state.replace(/\s+/g, '-'), title: s.step + ' ' + s.label + ': ' + s.state })); }); + var nrun = planned.filter(function (s) { return s.state === 'running'; }).length; var failed = planned.filter(function (s) { return s.state === 'failed'; }).length; - var run = (state && state.run) || {}; var t = done + ' of ' + planned.length + ' steps done'; if (run.state === 'finished') { t = 'Finished' + (run.finished_utc ? ' ' + U.day(run.finished_utc) : '') + ': ' + t; } - else if (running) { t = 'Running: ' + t + ', ' + running + ' running now'; } + else if (nrun) { t = 'Running: ' + t + ', ' + nrun + ' running now'; } if (failed) { t += ', ' + failed + ' failed'; } var rc = state && state.plugins && state.plugins.recheck; if (rc && rc.checked_utc) { t += '. Databases re-checked ' + U.day(rc.checked_utc); } - txt.textContent = t + '.'; + document.getElementById('strip-text').textContent = t + '.'; + runHero(planned, done, nrun, failed); + root.setAttribute('data-pgp-run', run.state || ''); + } + // While the run goes: the hero (31 of 54), the timing and what runs now. + function runHero(planned, done, nrun, failed) { + var hero = document.getElementById('runhero'); + var badge = document.getElementById('live-badge'); + hero.hidden = !running(); + if (badge) { badge.hidden = !running(); } + var list = document.getElementById('running-list'); + var box = document.getElementById('running-now'); + list.textContent = ''; + var now = Date.parse((state && state.generated_utc) || ''); + planned.filter(function (s) { return s.state === 'running'; }).forEach(function (s) { + var li = el('li', {}); + var el2 = isFinite(now) && s.started_utc ? (now - Date.parse(s.started_utc)) / 60e3 : null; + var usual = s.hours ? s.hours * 60 : null; + add(li, add(el('div', { 'class': 'rn' }), el('span', {}, s.label), + el('span', {}, (el2 !== null ? Math.round(el2) + ' min' : '') + (usual ? ' of ~' + Math.round(usual) + ' min' : '')))); + if (el2 !== null && usual) { + var pb = el('div', { 'class': 'pb' }); + var i = el('i', {}); + i.style.setProperty('--p', Math.min(100, 100 * el2 / usual).toFixed(1) + '%'); + add(li, add(pb, i)); + } + add(list, li); + }); + box.hidden = !list.childNodes.length; + if (!running()) { return; } + var ops = state.plugins && state.plugins.ops; + // hours_left is the ops plugin's {low_s, high_s} range from the usual step times, or null. + var hl = ops && ops.hours_left; + var left = hl && typeof hl.high_s === 'number' ? hl.high_s / 3600 : null; + var low = hl && typeof hl.low_s === 'number' ? hl.low_s / 3600 : null; + var span = left === null ? null : (low !== null && Math.round(low) !== Math.round(left) ? Math.max(0, Math.round(low)) + '–' + Math.max(1, Math.round(left)) + ' h' : '~' + Math.max(1, Math.round(left)) + ' h'); + var retries = ops && ops.memory ? ops.memory.retries || 0 : 0; + var alerts = (ops && ops.alerts) || []; + var lede = document.getElementById('run-lede'); + lede.textContent = ''; + add(lede, 'This report is being written as the steps finish, each section in its place. ', + el('b', {}, done + ' of ' + planned.length + ' are done'), + span !== null ? ', about ' + span.replace(' h', ' hours') + ' remain. ' : '. ', + failed ? el('b', {}, failed + ' failed: the timeline says which. ') : '', + alerts.length ? alerts.map(function (a) { return a.text; }).join('; ') + '. ' : (failed ? '' : 'Nothing needs you. '), + findingsNow().length ? 'The first findings are already in.' : 'No section is written yet.'); + var big = document.getElementById('bignum'); + big.textContent = String(done); + add(big, el('small', {}, 'of ' + planned.length + ' steps')); + var bar = document.getElementById('stepbar'); + bar.textContent = ''; + [['d', done], ['r', nrun], ['f', failed]].forEach(function (x) { + if (!x[1]) { return; } + var i = el('i', { 'class': x[0] }); + i.style.setProperty('width', (100 * x[1] / Math.max(1, planned.length)).toFixed(2) + '%'); + add(bar, i); + }); + var key = document.getElementById('stepkey'); + key.textContent = ''; + add(key, el('span', {}, done + ' done'), el('span', {}, nrun + ' running'), + el('span', {}, (planned.length - done - nrun - failed) + ' waiting'), el('span', {}, failed + ' failed')); + var timing = document.getElementById('timing'); + timing.textContent = ''; + var started = state.run.started_utc || (planned.map(function (s) { return s.started_utc; }).filter(Boolean).sort()[0]); + var el3 = U.hours(started, state.generated_utc); + [['Elapsed', el3 !== null ? el3.toFixed(1) + ' h' : 'not known', started ? 'since ' + U.hm(started) : ''], + ['To go', span !== null ? span : 'not known', span !== null ? 'from usual step times' : 'no usual times yet'], + ['Retries', String(retries), retries ? 'recovered unless a step failed' : 'none']].forEach(function (x) { + add(timing, add(el('div', {}), el('span', { 'class': 'k' }, x[0]), el('span', { 'class': 'v' }, x[1]), el('span', { 'class': 'r' }, x[2]))); + }); + } + + // --- the route: #s/, #s, #f/, #q/, #progress ----------------------------- + var CHAPTER_SECTION = { quality: 5, clinical: 8, carrier: 3, medicines: 2, ancestry: 6, traits: 7, other: 10, look: 1, leaves: 9 }; + function route() { + var h; + try { h = decodeURIComponent((window.location.hash || '').slice(1)); } catch (e) { return; } + if (!h) { return; } + var target = null, isCard = false; + if (h.indexOf('s/') === 0 || (h && !/^(s\d+|f\/.*|q\/.*|progress)$/.test(h) && cardById(h))) { + target = document.getElementById('card-' + (h.indexOf('s/') === 0 ? h.slice(2) : h)); + isCard = true; + } else if (h.indexOf('f/') === 0) { target = document.getElementById('f-' + h.slice(2)); } + else if (h.indexOf('q/') === 0) { target = document.getElementById('s' + (CHAPTER_SECTION[h.slice(2)] || 1)); } + else { target = document.getElementById(h); } + if (!target) { return; } + var d = target.closest && target.closest('details'); + if (d) { d.open = true; } + if (isCard) { + Array.prototype.forEach.call(panel.querySelectorAll('.result.target'), function (x) { x.classList.remove('target'); }); + target.classList.add('target'); + } + target.scrollIntoView({ block: 'start' }); + if (target.focus) { target.focus({ preventScroll: true }); } + Array.prototype.forEach.call(rail.querySelectorAll('[data-toc]'), function (a) { + var sec = target.closest ? target.closest('section.sec') : null; + a.setAttribute('aria-current', sec && sec.id === 's' + a.getAttribute('data-toc') ? 'true' : 'false'); + }); } + window.addEventListener('hashchange', route); // --- data --------------------------------------------------------------------------------------- function load() { @@ -495,27 +885,46 @@ fetchedFor = key; U.getJSON('results.json').then(function (r) { if (key !== fetchedFor) { return; } - if (!(r && (r.chapters || r.note))) { fetchedFor = null; return; } // tried again on the next state + if (!(r && (r.chapters || r.note))) { fetchedFor = null; return; } results = r; redraw(true); + route(); }).catch(function () { if (key === fetchedFor) { fetchedFor = null; } }); } function redraw(force) { - var k = JSON.stringify([doneSet(), state && state.run && state.run.state, !!results, - state && state.plugins && state.plugins.recheck && state.plugins.recheck.mtime]); - if (!force && k === drawnKey) { return; } + var k = JSON.stringify([doneSet(), state && state.run && state.run.state, !!results, !!family, Object.keys(shown), + state && state.plugins && state.plugins.recheck && state.plugins.recheck.mtime, + // Every step's state: one can move from waiting to not finished with the done set unchanged. + ((state && state.steps) || []).map(function (s) { return s.step + ':' + s.state; })]); + if (!force && k === drawnKey) { header(); return; } drawnKey = k; + var y = window.scrollY; + var open = Array.prototype.map.call(panel.querySelectorAll('details[open]'), function (d) { + return d.getAttribute('data-card') || (d.closest('[id]') || {}).id; }); header(); - var keep = document.activeElement && rail.contains(document.activeElement) ? document.activeElement.getAttribute('data-tab') : null; - drawRail(); - if (!chosen && !parse()) { show(defaultTab()); route(false); } else { route(false); } - if (keep) { var t = rail.querySelector('[data-tab="' + keep + '"]'); if (t) { t.focus(); } } + drawToc(); + drawTabbar(); + drawLead(); + drawSections(); + open.forEach(function (id) { + var d = panel.querySelector('details[data-card="' + id + '"]'); + if (d) { d.open = true; } + }); + window.scrollTo(0, y); + document.getElementById('progress-q').textContent = running() + ? 'Is it moving, when will it end, is anything wrong? One line per step.' : 'One line per step: when it ran, and what it found.'; + // While the run goes the timeline leads the page; once it ends it closes the report. + var content = document.getElementById('content'); + var prog = document.getElementById('progress'); + if (running() && content.firstElementChild !== prog) { content.insertBefore(prog, content.firstElementChild); } + else if (!running() && content.lastElementChild !== prog) { content.appendChild(prog); } } window.PGP.register({ name: 'person', onState: function (s) { state = s; + loadFamily(); if (!replay || !results) { load(); } redraw(false); } diff --git a/bin/live_results.py b/bin/live_results.py index eb80cdca..b34d7d28 100644 --- a/bin/live_results.py +++ b/bin/live_results.py @@ -24,6 +24,7 @@ fields the summary already holds. A section id with no adapter falls back to its report headline and a link to the report. """ +import hashlib import re import render_report as rr @@ -640,7 +641,7 @@ def card(sid, title, keys, summary, files): cav = caveats(sid) out = {"id": sid, "title": title, "keys": list(keys), "steps": steps, "state": state, "anchor": sid, "attention": False, "headline": None, "facts": [], "table": None, "lines": [], "caveat": cav, - "empty_text": EMPTY.get(sid), "links": [], "notes": []} + "empty_text": EMPTY.get(sid), "links": [], "notes": [], "flagged": False} for k, sec in zip(keys, secs): if sec.get("state") in ("failed", "unreadable", "stale"): out["notes"].append(f"{sec.get('title') or k}: {plain(rr.stale_note(sec))}") @@ -656,6 +657,7 @@ def card(sid, title, keys, summary, files): tones.append(tone) if "attention" in tones: out["attention"] = True + out["flagged"] = True if out["headline"]: # The step headline carries its caveat (the Progress tab shows no other); the card prints it below. for c in sorted({plain(c) for c in cav}, key=len, reverse=True): @@ -674,6 +676,7 @@ def card(sid, title, keys, summary, files): out["lines"] = [plain(x) for x in got.get("lines") or [] if plain(x) and plain(x) not in notes and plain(x) != out["headline"]] out["attention"] = out["attention"] or bool(got.get("attention")) + out["flagged"] = out.get("flagged", False) or bool(got.get("attention")) out["links"] = _links(got.get("links"), files) for k in ("widget", "explore", "image", "hidden", "viewer"): if got.get(k): @@ -681,6 +684,373 @@ def card(sid, title, keys, summary, files): return out +# --- the Journal: one level per result, the findings, the lead --------------------------------- +# The live site reads each person's results as a report in ten numbered sections (docs/design/journal/). +# A finding is one row the summary already holds (a ClinVar hit, a carrier gene, a changed-function +# medicine gene) with its level on the one scale every page uses. Nothing here grades anything the +# report does not: each rule below names the field it reads, and a card the report flags that no rule +# turns into a finding still becomes one, at "review", worded by its own headline. + +LEVELS = ("act", "medicine", "review", "carrier", "trust", "info") +LEVEL_WORDS = {"act": "Take to a doctor", "medicine": "Tell your prescriber", "review": "Worth a look", + "carrier": "For family planning", "trust": "Data quality", "info": "For information"} +# (number, key, title, question). The live run uses the same numbers, so a section being written sits +# where it will sit in the finished report. +JOURNAL = ( + (1, "act", "To act on", "Is there anything to take to a doctor?"), + (2, "medicines", "Medicines", "Which medicines should a prescriber know about?"), + (3, "family", "For your family", "What could I pass on to a child?"), + (4, "look", "Worth a look", "What deserves a conversation, but is not a diagnosis?"), + (5, "trust", "Can I trust this?", "This is about the data, not about the person."), + (6, "origins", "Origins", "Where do my ancestors come from?"), + (7, "traits", "Traits and scores", "What do studies say about traits and risk scores?"), + (8, "assessed", "Assessed, no finding", "What each step looked at, and what it does not see."), + (9, "cannot", "What it cannot see", "What no step of this run looks at."), + (10, "sources", "Sources and files", "The databases, the report and the files behind this page."), +) +LEVEL_SECTION = {"act": 1, "medicine": 2, "carrier": 3, "review": 4, "trust": 5} +CHAPTER_SECTION = {"quality": 5, "clinical": 8, "carrier": 3, "medicines": 2, "ancestry": 6, "traits": 7, "other": 10} +# What to do, by level: the same few steps for every finding of a level, never advice about one gene. +PLAN = { + "act": ["Bring this page to a doctor or a genetic counsellor.", + "Ask for a clinical test in a certified laboratory to confirm it: this pipeline is research software.", + "Ask what it means for parents, brothers, sisters and children: close relatives may carry it too."], + "medicine": ["Show this to whoever prescribes for you, and to your pharmacist.", + "Do not change a medicine on your own: the guidance is written for the prescriber.", + "Ask for a clinical pharmacogenetic test before a prescription changes."], + "review": ["Read the evidence and its caveat below.", + "Talk it over with a doctor or a genetic counsellor if it bears on your health or a pregnancy.", + "A measurement or a clinical test settles it: this result alone is not a diagnosis."], + "carrier": ["A carrier is not affected by the condition.", + "It matters when a partner carries a change in the same gene: compare with the couple screen.", + "Relatives planning a pregnancy may carry it too."], + "trust": ["Read nothing else in this report until this is understood.", + "Check the sample's label and the run's inputs, then run it again."], +} +STR_WORDS = {"pre_mutation": "premutation range", "full_mutation": "full-mutation range"} +CARRIER_NOTE = "A carrier has one changed copy of a gene where it takes two to cause the condition." + + +def slug(*parts): + """A finding's id: lower case, digits and dashes, at most 80 characters (the route's own pattern).""" + raw = "-".join(str(p) for p in parts if p not in (None, "")) + t = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-") + if len(t) > 80: # a long allele: cut, and keep two ids apart by a hash of the whole key + t = t[:71].rstrip("-") + "-" + hashlib.sha1(raw.encode()).hexdigest()[:8] + return t or "finding" + + +FINDING_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$") + + +def _f(level, card_id, key, title, text, gene=None, detail=None, evidence=(), viewer=None, af=None): + return {"id": slug(*key), "level": level, "card": card_id, "title": plain(title), "text": plain(text), + "gene": gene, "detail": plain(detail) if detail else None, + "evidence": [[plain(a), plain(cell(b))] for a, b in evidence if b not in (None, "", ".")], + "viewer": viewer, "af": af} + + +def _data(S, k): + sec = S.get(k) or {} + return sec.get("data") or {} if sec.get("state") in ("ok", "stale") else {} + + +def _copies(n): + return "two copies" if n == "hom" else "one copy" if n == "het" else (n or "genotype not given") + + +def finding_rows(summary): + """[finding] from the rows of the summary, each with its level, in the order of the rules.""" + S = summary.get("sections") or {} + out = [] + sf = (_data(S, "clinical").get("acmg_sf") or {}) + sf_genes = {x.get("gene") for x in sf.get("clinvar_hits") or []} + car = _data(S, "carrier") + carrier_genes = {c.get("gene") for c in car.get("carriers") or []} + two_hit = {g if isinstance(g, str) else g.get("gene") for g in (car.get("two_hits") or {}).get("genes") or []} + # 1. ACMG secondary findings: the gene's own reporting rule already said "report". + for r in rr.sf_rows(sf) if sf and not sf.get("unavailable") else []: + out.append(_f("act", "clinical-filter", ("sf", r[0], r[1]), f"Secondary finding in {r[0]}", + f"A change in {r[0]} that the ACMG secondary-findings list reports: {r[3]}.", + gene=r[0], detail=f"{r[0]} {r[1]} {r[2] if r[2] != '.' else ''}".strip(), + evidence=[["Variant", r[1]], ["Genotype", r[2]], ["Why it is reported", r[3]]])) + # 2. Cancer predisposition, CPSR's pathogenic and likely pathogenic rows. + for x in _data(S, "cpsr").get("plp") or []: + out.append(_f("act", "cpsr", ("cpsr", x.get("gene"), x.get("variant")), + f"Cancer predisposition ({x.get('gene')})", + f"CPSR grades this change in {x.get('gene')} as {x.get('final')}: it can raise the risk of some " + f"cancers. It is not a diagnosis of cancer.", gene=x.get("gene"), + detail=f"{x.get('gene')} {x.get('variant')}", + evidence=[["Class", x.get("final")], ["ClinVar", x.get("clinvar")], + ["Call", "low confidence" if x.get("low_confidence") else None]])) + # 3. Two hits in one carrier-list gene: possibly both copies changed. + for g in sorted(x for x in two_hit if x): + out.append(_f("act", "carrier-list", ("two-hits", g), f"Two changes in {g}", + f"Two changes were found in {g}, a gene where it takes two to cause the condition. Whether they " + f"sit on different copies decides whether it matters.", gene=g)) + # 4. ClinVar P/LP hits not already a secondary finding or a carrier row. + for h in (_data(S, "clinvar").get("hits") or [])[:50]: + g = h.get("gene") + if g in sf_genes or g in carrier_genes or g in two_hit: + continue + var = f"{h.get('chrom')}:{h.get('pos')} {h.get('ref')}>{h.get('alt')}" + out.append(_f("review", "clinvar", ("clinvar", g, h.get("chrom"), h.get("pos"), h.get("ref"), h.get("alt")), + f"{h.get('significance') or 'Pathogenic'} change in {g}", + f"ClinVar lists this change in {g} as {str(h.get('significance') or '').lower()}; this genome " + f"has {_copies(h.get('genotype'))}. Whether it matters depends on how the gene is inherited: " + f"a genetic counsellor reads it with the rest of this report.", + gene=g, detail=f"{g} {var} {h.get('genotype') or ''}".strip(), + evidence=[["Significance", h.get("significance")], ["ClinVar review", "★" * int(h.get("stars") or 0) or "0 stars"], + ["Genotype", h.get("genotype")], ["Frequency", rr.af_text(h) or "frequency unknown"], + ["Call", clinvar_call(h)]], + viewer=f"{h.get('chrom')}:{h.get('pos')}", af=h.get("af"))) + # 5. Large deletions and duplications AnnotSV ranks class 4 or 5. + for x in _data(S, "annotsv").get("rows") or []: + genes = ", ".join(x.get("genes") or []) or "no gene" + out.append(_f("act" if str(x.get("acmg_class")) == "5" else "review", "structural-variants", + ("sv", x.get("sv")), f"Large {x.get('type') or 'structural change'} over {genes}", + f"A {x.get('type') or 'structural change'} of {abs(int(num(x.get('length')) or 0)):,} bases that " + f"AnnotSV ranks class {x.get('acmg_class')}. Short reads can misplace large changes: confirm it.", + gene=(x.get("genes") or [None])[0], detail=x.get("sv"), + evidence=[["Class", x.get("acmg_class")], ["Callers", rr.annotsv_support_text(x)], + ["Genotype", x.get("gt")]], viewer=str(x.get("sv") or "").replace(" ", "") or None)) + # 6. Repeat expansions outside Stranger's "normal", or in an intermediate zone. + for l in _data(S, "expansions").get("loci") or []: + st = [x for x in (l.get("status") or "").split(",") if x and x != "normal"] + if not st and not l.get("zone"): + continue + full = any("full" in x for x in st) + lead_st = next((x for x in st if "full" in x), st[0]) if st else None + what = (STR_WORDS.get(lead_st, lead_st.replace("_", " ")) if lead_st + else f"{l.get('zone_label') or 'intermediate'} range") + count = rr.str_count(l) if l.get("repeat_count") else "not given" + out.append(_f("act" if full else "review", "expansions", ("str", l.get("locus")), + f"{l.get('locus')} repeat in the {what}", + f"{l.get('locus')} has {count} repeats: Stranger reads it as \"{what}\". " + f"{'See a clinical geneticist.' if full else 'It may grow when passed on; worth a word with a genetic counsellor.'}", + gene=l.get("locus"), detail=f"{l.get('locus')} {count} repeats", + evidence=[["Repeats", count], ["95% interval", l.get("ci")], + ["Pathologic from", l.get("pathologic_min")], + ["Sizing", l.get("allele_sizing") or l.get("sizing")]])) + v = _data(S, "vntyper") + if v.get("status") in ("positive", "possible"): + out.append(_f("act" if v["status"] == "positive" else "review", "vntyper", ("muc1", v["status"]), + f"MUC1 kidney gene: {v['status']}", f"VNtyper reads the MUC1 repeat as {v['status']}; it needs a " + f"clinical confirmation.", gene="MUC1", evidence=[["Confidence", v.get("confidence")]])) + lp = _data(S, "lpa") + if lp.get("flag") == "high": + out.append(_f("review", "lpa", ("lpa",), "Lp(a) likely high", + "The KIV-2 count suggests a high Lp(a); one blood measurement settles it.", gene="LPA", + evidence=[["KIV-2 repeats", lp.get("kiv2_copies")], ["Class", lp.get("class")]])) + for c in _data(S, "chip").get("calls") or []: + out.append(_f("review", "chip", ("chip", c.get("gene"), c.get("protein_change")), + f"A blood clone in {c.get('gene')} (CHIP)", + f"{c.get('gene')} {c.get('protein_change') or ''} is carried by a share of the blood cells (VAF " + f"{c.get('vaf')}). It becomes common with age; most people with it never get a blood cancer.", + gene=c.get("gene"), evidence=[["VAF", c.get("vaf")], ["Reads", f"{c.get('alt_reads')} of {c.get('depth')}"]])) + for c in _data(S, "mosaic").get("calls") or []: + out.append(_f("review", "mosaic-mca", ("mca", c.get("chrom"), c.get("start")), + f"A {c.get('type') or 'change'} on {c.get('chrom')} in some blood cells", + f"MoChA sees a {c.get('type') or 'change'} of {c.get('length_mb')} Mb on {c.get('chrom')} in a share " + f"of the blood cells ({c.get('cell_fraction')}).", + evidence=[["Where", f"{c.get('chrom')}:{c.get('start')}-{c.get('end')}"], + ["Cell fraction", c.get("cell_fraction")]])) + for x in (_data(S, "exomiser").get("genes") or [])[:3]: + out.append(_f("review", "exomiser", ("exomiser", x.get("gene")), f"Symptom ranking: {x.get('gene')} ranks {x.get('rank')}", + "A ranking to bring to a clinical geneticist, not a diagnosis.", gene=x.get("gene"), + evidence=[["Score", x.get("exomiser_score")], ["Inheritance", x.get("moi")]])) + # 7. Carriers: one changed copy in a gene of the carrier list, and the couple screen. + for c in car.get("carriers") or []: + if c.get("gene") in two_hit: + continue + gt = str(c.get("genotype") or "") + if gt in ("hom", "hemizygous"): + # Both copies changed, or the only copy (X in a male, or inside a deletion): not a carrier. + out.append(_f("act", "carrier-list", ("carrier", c.get("gene"), c.get("variant")), + f"{c.get('condition') or c.get('gene')}: {'two changed copies' if gt == 'hom' else 'the only copy changed'}", + f"This genome has {'two changed copies' if gt == 'hom' else 'no unchanged copy'} of " + f"{c.get('gene')}, a gene of {c.get('condition') or 'a condition on the carrier list'} " + f"({c.get('inheritance') or 'inheritance not given'}). That is not the pattern of a carrier: " + f"a doctor or genetic counsellor reads it.", + gene=c.get("gene"), detail=f"{c.get('gene')} {c.get('variant') or ''} {gt}".strip(), + evidence=[["Genotype", gt], ["Significance", c.get("significance")], ["Stars", c.get("stars")], + ["Inheritance", c.get("inheritance")]])) + continue + out.append(_f("carrier", "carrier-list", ("carrier", c.get("gene"), c.get("variant")), + f"{c.get('condition') or c.get('gene')} carrier" if c.get("condition") else f"Carrier in {c.get('gene')}", + f"{CARRIER_NOTE} This genome has one changed copy of {c.get('gene')}: a carrier, not affected.", + gene=c.get("gene"), detail=f"{c.get('gene')} {c.get('variant') or ''}".strip(), + evidence=[["Significance", c.get("significance")], ["Stars", c.get("stars")], + ["Inheritance", c.get("inheritance")]])) + cp = _data(S, "couple") + for lab, key in (("Both partners carry", "shared"), ("X-linked", "x_linked")): + for x in cp.get(key) or []: + out.append(_f("carrier", "couple", ("couple", key, x.get("gene")), f"{lab} {x.get('gene')}" + if key == "shared" else f"X-linked finding in {x.get('gene')}", + f"{x.get('condition') or x.get('gene')}: {x.get('risk') or 'see the couple screen'} for each " + f"pregnancy, with {cp.get('partner')}.", gene=x.get("gene"), + evidence=[["Chance for a child", x.get("risk")], ["Partner", cp.get("partner")]])) + smn1 = [g for g in _data(S, "paralogs").get("genes") or [] if g.get("gene") == "SMN1" and g.get("status") == "called"] + one = smn1 and str(smn1[0].get("copy_number")) == "1" + by = "Paraphase" + if not smn1: # no Paraphase call: Parascopy's resolved count from short reads + cn = ((_data(S, "parascopy").get("smn1") or {}).get("cn")) + one, by = cn == 1, "Parascopy" + if one: + out.append(_f("carrier", "paralogs", ("smn1",), "Spinal muscular atrophy carrier (SMN1)", + "One copy of SMN1 where most people have two: a carrier of spinal muscular atrophy, not " + "affected.", gene="SMN1", evidence=[["SMN1 copies", "1"], ["Called by", by]])) + # 8. Medicines: each gene whose called phenotype changes function. + for g in _data(S, "cpic").get("genes") or []: + if g.get("status") not in ("non-normal", "ambiguous"): + continue + amb = g["status"] == "ambiguous" + out.append(_f("medicine", "cpic", ("pgx", g.get("gene")), + f"{g.get('gene')}: {g.get('phenotype')}" if not amb else f"{g.get('gene')}: more than one possible result", + f"{g.get('gene')} {g.get('diplotype')} reads as \"{g.get('phenotype')}\" in CPIC's words. The drugs " + f"it bears on are in the CPIC report; tell whoever prescribes.", gene=g.get("gene"), + detail=f"{g.get('gene')} {g.get('diplotype')}", + evidence=[["Diplotype", g.get("diplotype")], ["Phenotype", g.get("phenotype")], ["Status", g.get("status")]])) + # 9. Trust: a problem with the data itself. + q = _data(S, "sample_qc") + if q.get("sex_check") == "mismatch": + out.append(_f("trust", "qc", ("sex",), "Declared and measured sex differ", + "The sex the reads show differs from the one declared: a swapped or mislabelled sample, or a real " + "sex-chromosome difference.", evidence=[["Declared", q.get("declared_sex")], ["From the reads", q.get("inferred_sex")]])) + if q.get("contamination") == "warn": + out.append(_f("trust", "qc", ("contamination",), "Another person's DNA may be mixed in", + rr.freemix_text(q), evidence=[["FREEMIX", q.get("freemix")]])) + if q.get("same_person_as"): + out.append(_f("trust", "qc", ("same-person",), f"The same person as {q['same_person_as']}", + "Two runs of this server hold the same genome.", evidence=[["Matches", q["same_person_as"]]])) + seen, uniq = set(), [] + for x in out: + if x["id"] not in seen: + seen.add(x["id"]) + uniq.append(x) + return uniq + + +def level_of(card, found): + """A card's level: its highest finding's, else data quality for the quality chapter, else review + when the report flags it, else information. A failed or missing card has no level (None).""" + if card.get("state") in ("missing", "failed"): + return None + mine = [f["level"] for f in found if f["card"] == card["id"]] + for lv in LEVELS: + if lv in mine: + return lv + if card.get("chapter") == "quality": + return "trust" + return "review" if card.get("flagged") else "info" + + +def journal(chapters, summary): + """findings, levels and the ten sections of one person's report, from the cards build() made.""" + found = finding_rows(summary) + cards = [] + for ch in chapters: + for c in ch["sections"]: + c["chapter"] = ch["key"] + cards.append(c) + by_id = {c["id"]: c for c in cards} + have = {f["card"] for f in found} + # A card the report flags (its headline or its own rule), for which no row rule found a finding, + # still becomes one: worded by its headline, at review. + for c in cards: + if c.get("flagged") and c["id"] not in have and c.get("state") not in ("missing", "failed"): + lv = "trust" if c["chapter"] == "quality" else "review" + found.append(_f(lv, c["id"], ("card", c["id"]), c["title"], c.get("headline") or + f"The report flags this section: {c['title']}.", evidence=(c.get("facts") or [])[:4])) + for f in found: + c = by_id.get(f["card"]) or {} + f["section"] = LEVEL_SECTION[f["level"]] + f["steps"] = c.get("steps") or [] + f["caveat"] = c.get("caveat") or [] + f["anchor"] = c.get("anchor") or f["card"] + f["plan"] = PLAN[f["level"]] + f["level_words"] = LEVEL_WORDS[f["level"]] + for c in cards: + c["level"] = level_of(c, found) + lv = [f["level"] for f in found if f["card"] == c["id"]] + top = next((x for x in LEVELS if x in lv), None) + home = CHAPTER_SECTION.get(c["chapter"], 10) + if c["chapter"] == "clinical" and top in LEVEL_SECTION: + home = LEVEL_SECTION[top] + c["section"] = home + c["findings"] = [f["id"] for f in found if f["card"] == c["id"]] + for f in found: + f["related"] = [g["id"] for g in found if g["id"] != f["id"] and + ((f["gene"] and g["gene"] == f["gene"]) or g["card"] == f["card"])][:6] + sections = [] + for n, key, title, question in JOURNAL: + sections.append({"n": n, "key": key, "title": title, "question": question, + "findings": [f["id"] for f in found if f["section"] == n], + "cards": [c["id"] for c in cards if c["section"] == n]}) + levels = {lv: sum(1 for f in found if f["level"] == lv) for lv in LEVELS if lv != "info"} + levels["info"] = sum(1 for c in cards if c["level"] == "info") + return found, levels, sections + + +def _names(found, n=3): + t = [f["title"] for f in found[:n]] + more = len(found) - len(t) + s = ", ".join(t[:-1]) + (" and " if len(t) > 1 else "") + t[-1] if t else "" + return s + (f", and {more} more" if more > 0 else "") + + +def _count(n, one, many): + words = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", 7: "seven", 8: "eight", 9: "nine"} + return f"{words.get(n, n)} {one if n == 1 else many}" + + +def screened(summary): + """True when a clinical screen (ClinVar, the clinical filter or CPSR) has a result: only then can the + lead or the home page say that no finding needs a doctor.""" + S = summary.get("sections") or {} + return any((S.get(k) or {}).get("state") in ("ok", "stale") for k in ("clinvar", "clinical", "cpsr")) + + +def lead(found, levels, summary): + """The report's lead paragraph: [[text, bold]] segments (a third item "act" marks the names of the + findings to act on), plain sentences that name the findings by level. It never says a section is fine: with no finding of a level it says what was assessed.""" + S = summary.get("sections") or {} + by = {lv: [f for f in found if f["level"] == lv] for lv in LEVELS} + out = [] + if by["trust"]: + out += [["Read the data-quality section first: ", True], [_names(by["trust"]) + ". ", False]] + if by["act"]: + n = len(by["act"]) + out += [["Of everything this genome was screened for, ", False], + [_count(n, "finding should", "findings should") + " go to a doctor", True], + [": " + _names(by["act"]) + ". ", False, "act"]] # "act": the names a child's page keeps closed + elif screened(summary): + out += [["No finding in what these steps assessed needs a doctor; ", True], + ["sections 8 and 9 say what they do not see. ", False]] + else: # no clinical screen has a result: saying nothing needs a doctor would read as an all-clear + out += [["The clinical screens have no result yet; ", True], + ["section 9 lists the steps that did not run. ", False]] + if by["medicine"]: + genes = sorted({f["gene"] for f in by["medicine"] if f["gene"]}) + out += [[_count(len(by["medicine"]), "medicine result", "medicine results").capitalize(), True], + [f" bear{'s' if len(by['medicine']) == 1 else ''} on how drugs work ({', '.join(genes)}): tell whoever prescribes. ", False]] + if by["carrier"]: + out += [[f"A carrier of {_names(by['carrier'], 2)}", True], + [": carriers are not affected; it matters when a partner carries the same gene. ", False]] + if by["review"]: + out += [[_count(len(by["review"]), "result is", "results are").capitalize() + " worth a look", True], + [": " + _names(by["review"]) + ". ", False]] + cov = _data(S, "coverage").get("mean_depth") + q = _data(S, "sample_qc") + checked = q.get("contamination") == "ok" and q.get("sex_check") == "ok" # both ran and raised nothing + if not by["trust"] and isinstance(cov, (int, float)): + out.append([f"Mean depth {cov:.1f}×" + ("; the identity and contamination checks raised no flag." if checked + else "; the identity and contamination checks have no result here: section 5 says why."), False]) + if out: + out[-1][0] = out[-1][0].rstrip() + return out + + def build(sample, summary, files=()): """results.json of one person from a summary.json; files: the run's paths the report links (for links).""" summary = summary or {} @@ -694,8 +1064,12 @@ def build(sample, summary, files=()): sexc = (S.get("sex_check") or {}).get("data") or {} person = {"sample": sample, "declared_sex": (summary.get("run") or {}).get("declared_sex"), "inferred_sex": sexc.get("inferred_sex"), "karyotype": sexc.get("karyotype")} + found, levels, sections = journal(chapters, summary) return {"schema_version": SCHEMA, "sample": sample, "generated_utc": summary.get("generated_utc"), "person": person, "chapters": chapters, + "findings": found, "levels": levels, "journal": sections, "lead": lead(found, levels, summary), + "level_words": LEVEL_WORDS, "databases": {k: plain(cell(v)) for k, v in (summary.get("databases") or {}).items() + if isinstance(v, (str, int, float))}, "attention": sum(c["attention"] for c in chapters), "not_assessed": [plain(x) for x in summary.get("not_assessed") or []], "not_run": [plain(x) for x in summary.get("not_run") or []], @@ -708,6 +1082,20 @@ def attention_count(summary): return build("", summary)["attention"] +def overview(summary): + """What the home page shows of one person: the attention count, the counts by level, the findings to + act on first (at most four, by level) and the lead as plain text. No file read.""" + r = build("", summary) + order = {lv: i for i, lv in enumerate(LEVELS)} + order["trust"] = -1 # a problem with the data comes before any finding read from it + top = sorted((f for f in r["findings"] if f["level"] != "info"), key=lambda f: order[f["level"]])[:4] + return {"attention": r["attention"], "levels": r["levels"], "screened": screened(summary), + "top": [{"id": f["id"], "level": f["level"], "title": f["title"]} for f in top], + "lead": "".join(x[0] for x in r["lead"]), + # The same lead with the names of the findings to act on left out: a child's tile shows this one. + "lead_closed": "".join(x[0] if x[2:] != ["act"] else ": closed until opened on purpose. " for x in r["lead"])} + + def strings(obj): """Every string in a JSON value, with its key path: the wording gate's input.""" if isinstance(obj, dict): diff --git a/bin/owngenome_live.py b/bin/owngenome_live.py index eaa7a993..e24ebcb5 100755 --- a/bin/owngenome_live.py +++ b/bin/owngenome_live.py @@ -89,7 +89,7 @@ RUN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") # validate_sample in scripts/lib/common.sh ASSET_RE = re.compile(r"^(?:plugins/)?[A-Za-z0-9][A-Za-z0-9._-]*$") # The page templates of assets/live: served filled in, never as an asset. -PAGES = ("index.html", "login.html", "home.html", "family.html") +PAGES = ("index.html", "login.html", "home.html", "family.html", "finding.html") TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", ".txt": "text/plain; charset=utf-8", ".tsv": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".pdf": "application/pdf"} @@ -524,6 +524,7 @@ def __init__(self, app, cfg, password_hash): self.streams = threading.BoundedSemaphore(cfg.max_streams) self.headline_cache = {} self.attention_cache = {} + self.live_overview = {} # run -> (the headline cache key, live_results.overview of that summary) self.allowed = set(LOOPBACK_HOSTS) | {h.lower() for h in cfg.allowed_host or []} if cfg.bind not in ("0.0.0.0", "::"): self.allowed.add(cfg.bind.strip("[]").lower()) @@ -782,6 +783,12 @@ def index_page(self, kind=None): def attention(self, d): """(count of cards worth a look, mtime) from /summary.json alone, cached by its mtime; (None, None) without one. The home page reads no result file beyond that.""" + o, mtime = self.overview(d) + return (o["attention"] if o else None), mtime + + def overview(self, d): + """(live_results.overview of /summary.json, mtime), cached by its mtime; (None, None) without one: + the attention count, the counts by level, the first findings and the lead the home page shows.""" p = os.path.join(d, "summary.json") if d else None if not p or not self.inside(p, os.path.realpath(d)) or not self.inside(p, self.root): return None, None # a summary.json linking out of its run is never read @@ -797,7 +804,7 @@ def attention(self, d): try: import live_results with open(p) as f: - n = live_results.attention_count(json.load(f)) + n = live_results.overview(json.load(f)) except Exception as e: # one bad file must not take the home page down print(f"owngenome_live: {os.path.basename(os.path.dirname(p))}/summary.json not read: {type(e).__name__}", file=sys.stderr) @@ -807,11 +814,14 @@ def attention(self, d): def runs_index(self): """/runs.json: each run's progress, and the families with their members, for the home page. - No result beyond the attention count; no path.""" + No result beyond the attention count, the counts by level, the titles of the first four + findings and the lead; no path.""" runs = self.runs() # Forget the cached results of runs that are gone, so the caches hold only the runs there are. for k in [k for k in list(self.headline_cache) if k not in runs]: self.headline_cache.pop(k, None) + for k in [k for k in list(self.live_overview) if k not in runs]: + self.live_overview.pop(k, None) keep = {os.path.join(r["dir"] or os.path.join(self.root, n), "summary.json") for n, r in runs.items()} for k in [k for k in list(self.attention_cache) if k not in keep]: self.attention_cache.pop(k, None) @@ -827,8 +837,32 @@ def runs_index(self): running=sum(1 for s in planned if s["state"] == "running"), failed=sum(1 for s in planned if s["state"] == "failed")) # A run known from its messages alone still has its folder under the root once step 24 wrote there. - item["attention"], mtime = self.attention(r["dir"] or os.path.join(self.root, name)) + o, mtime = self.overview(r["dir"] or os.path.join(self.root, name)) + # While a run goes, the summary its page last read (results.json's own) is newer than + # summary.json: count from it, so the home never says less than the person page. + hit = self.headline_cache.get(name) if item["state"] == "running" else None + # Only while that summary is still current: the same steps done and the same summary.json. + # Once either moved, the page's next read replaces it; until then summary.json is the newer. + done = tuple(s["step"] for s in st["steps"] if s["state"] == "done") + sp = os.path.join(r["dir"] or os.path.join(self.root, name), "summary.json") + if hit and (hit[0][1] != done or hit[0][3] != self.file_stamp(sp)): + hit = None + if hit and hit[2]: + lo = self.live_overview.get(name) + if not lo or lo[0] != hit[0]: + try: + import live_results + lo = (hit[0], live_results.overview(hit[2])) + except Exception as e: # one bad summary must not take the home page down + print(f"owngenome_live: overview of {name} not built: {type(e).__name__}", file=sys.stderr) + lo = (hit[0], None) + self.live_overview[name] = lo + o = lo[1] or o + item["attention"] = o["attention"] if o else None item["results"] = mtime is not None + if o: # the Journal home: counts by level, the first findings, the lead + item.update(levels=o["levels"], top=o["top"], lead=o["lead"], lead_closed=o["lead_closed"], + screened=o.get("screened", False)) else: item["members"] = r.get("members") or [] out.append(item) diff --git a/bin/owngenome_live_results.py b/bin/owngenome_live_results.py index 1684cf04..e3698175 100644 --- a/bin/owngenome_live_results.py +++ b/bin/owngenome_live_results.py @@ -2,7 +2,10 @@ GET /runs//results.json bin/live_results.py's build() of the run's summary: the report's seven chapters, one card per section with its - facts, its rows, its caveats and its attention flag + facts, its rows, its caveats and its attention flag, and + the Journal's findings, levels, lead and ten sections + GET /runs//finding// one finding's page (assets/live/finding.html): 404 unless + is a finding of that run's results.json now The summary is the one the server already reads for the step headlines (Live.summary): the run's summary.json when step 24 wrote it after every done @@ -11,8 +14,10 @@ summary.json is never served: no run_file glob is added, and results.json holds what the report prints. Every request writes one access-log row. """ +import html import os import sys +import urllib.parse HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) @@ -49,3 +54,40 @@ def results(req, m): app.route("GET", r"/runs/(%s)/results\.json" % NAME, results) + def finding(req, m): + """One finding's page. The page itself reads ../../results.json; this only checks that the id is + a finding of the run now, so a typed or stale address is a plain 404, never an empty page.""" + live = req.live + # A page: without a session it sends the browser to the login and back, as the viewer does. + if live.password_hash and req.sid is None: + app.log("finding", m.group(1), "-", 303) + return req.send_body(303, "text/plain", "", [("Location", "/login?next=" + urllib.parse.quote( + urllib.parse.urlsplit(req.path).path))]) + run = live.run(m.group(1)) + fid = m.group(2) + ok = False + if run and run.get("kind") != "family" and live_results_mod().FINDING_ID.match(fid): + try: + d = run["dir"] or os.path.join(live.root, run["name"]) + summary = live.summary(run["name"], d, live.base_state(run)) + ok = bool(summary) and any(f["id"] == fid for f in live_results_mod().build(run["name"], summary)["findings"]) + except Exception as e: # a reader bug is a 404 here, never a 500 + print(f"owngenome_live: finding of {run['name']} not checked: {type(e).__name__}", file=sys.stderr) + if not ok: + app.log("finding", m.group(1), "-", 404) + return req.send_body(404, "text/plain; charset=utf-8", "not found\n") + with open(os.path.join(live.cfg.assets, "finding.html")) as f: + page = f.read() + page = (page.replace("{{assets}}", "/assets").replace("{{run}}", html.escape(run["name"])) + .replace("{{finding}}", html.escape(fid)).replace("{{mode}}", "live")) + app.log("finding", run["name"], fid, 200) + return req.send_body(200, "text/html; charset=utf-8", page) + + # auth=False: finding() asks for the login itself, so a bookmarked finding leads to the login form. + app.route("GET", r"/runs/(%s)/finding/([^/]{1,200})/" % NAME, finding, auth=False) + + +def live_results_mod(): + import live_results + return live_results + diff --git a/docs/images/demo-live-site.png b/docs/images/demo-live-site.png index db28216f..0cba14cb 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 8f7b3983..5b6bf323 100644 --- a/docs/live-site.md +++ b/docs/live-site.md @@ -1,6 +1,6 @@ # Watch a Run Live -A full run takes a day or more. The live site shows each person's results as their steps finish, in a browser on any device that can reach the machine: the report's chapters as tabs, one card per section with its numbers, its rows and its caveat, and what is worth a look first. A Progress tab keeps the step map. A home page lists everyone by family, and each family has a page of its own. The pages repaint as messages arrive; nobody has to reload them. +A full run takes a day or more. The live site shows each person's results as their steps finish, in a browser on any device that can reach the machine. Each genome reads as a report: a lead paragraph that says what to take to a doctor, what to tell a prescriber and what is worth a look, then ten numbered sections from what matters most to what the run cannot see, each finding with a page of its own. While the run goes, a timeline of its steps sits on top. A home page lists everyone by family, and each family has a report of its own. The pages repaint as messages arrive; nobody has to reload them. `bin/owngenome_live.py` is the server. It uses Python's standard library only, so the `python3` on the host is enough, and it has its own password, access log and limits, so it needs no web server in front of it. @@ -8,7 +8,7 @@ A full run takes a day or more. The live site shows each person's results as the https://geiserx.github.io/OwnGenome/demo/ is the live site on DEMO-001, the invented sample of the docs pictures. It replays a made-up run from the first task to the database re-check after it: press Play, or drag the slider, and watch the cards fill in as their steps end. Every number, result and family member in it is made up. The family is an invented trio: DEMO-001, the partner DEMO-002 and their son DEMO-003. Each of the three has a finished run with its own findings, so every card widget and the whole family page have something to draw: DEMO-002 carries an FMR1 premutation-range count, a CHIP call and a MoChA loss; DEMO-003 a class 4 AnnotSV deletion and a Y path. -![The person page of DEMO-001 at the end of the demo run: the name with a progress strip of 54 finished steps, a tab column (Look first, Trust, Doctor, Child, Medicines, Ancestors, Traits, Other, Leaves out, Progress) with Doctor open, and the ClinVar card marked worth a look and newer ClinVar available, with its counts and the chromosome chart of its hits](images/demo-live-site.png){ loading=lazy } +![The person page of DEMO-001 at the end of the demo run: the name with its family and run state, the contents of the ten sections with a count per level, the lead paragraph (no finding for a doctor, two medicine results, six worth a look), the five-level ledger, the data-quality line and the start of the chromosome map of the findings](images/demo-live-site.png){ loading=lazy } The pages and their plugins are the files of `assets/live/`, unchanged. Each moment of the replay is the server's own `state.json` at that moment: `tests/demo/build_live_demo.py` loads every plugin, stores the made-up messages of `tests/fixtures/live/demo-001.weblog.jsonl` one by one as the ingest port would, and reads the state after each. The cards come from the `results.json` the server would give at the end of the run; each shows once the replay reaches the end of its step. The home page, the charts, the search box, the run-health card, the database re-check and the family page work as they do on your own site. The genome viewer does not: it opens your own reads on your own computer, so its links lead to a page that says it is not in the demo. @@ -44,26 +44,35 @@ weblog { ## What the pages show -**Home** (`/`). Everyone, grouped by family, then the runs in no family. A card shows the pedigree symbol with a ring for the run's progress, the person's place in the family, the steps done and how many cards the report flags. Runs still going come first in each family. A family member with no run on this server is listed, dashed. Without scripts the page is a plain list of runs. +Every result carries one of six levels, with a word, a shape and a colour, so the shape alone tells them apart: **Take to a doctor** (◆), **Tell your prescriber** (a pill), **Worth a look** (▲), **For family planning** (two rings), **Data quality** (✓, red when the data has a problem) and **For information** (○). `bin/live_results.py` sets the level from the rows the report already prints: an ACMG secondary finding, a pathogenic CPSR row or two hits in a carrier-list gene is to act on; a ClinVar hit, an AnnotSV class 4, a repeat outside Stranger's normal, a CHIP or MoChA call or an Exomiser top gene is worth a look (a full-mutation repeat, an AnnotSV class 5 and a positive VNtyper call are to act on); a carrier-list row, a gene both partners carry and one SMN1 copy are for family planning; a CPIC gene with changed function is for the prescriber; a sex mismatch, a contamination warning or a second run of the same person is data quality. Everything else is for information. No page says a section is fine: a section with no finding says "No finding in what these steps assessed" and what it looked at. -**A person** (`/runs//`). The name, the declared and measured sex, the families they are in, and a strip with one cell per planned step. Below, one tab per chapter of the report, each named by its question: +**Home** (`/`). A date line, then one sentence on where everyone stands (how many reports are finished or being written, and whether any finding needs a doctor), and beside it the findings that need attention across everyone, one line per person and level, each opening its finding page. A run still going comes first, as "Being written", with its steps done and the sections already written. Then each family: a small pedigree in the members' colours and one tile per person, with the first lines of their report's lead and their counts by level. A family member with no run on this server is listed, dashed. Last, every run as a log: state, steps, hours, when it ended. Without scripts the page is a plain list of runs. -- **Look first** opens by default once a step has finished: the cards the report itself flags, in chapter order, each with its headline. A card not listed is for information; its caveats still apply. -- **Trust, Doctor, Child, Medicines, Ancestors, Traits, Other** follow the report's seven chapters. One card per section, with the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts. Under each card sits its caveat: the first sentence, then the rest behind "Full caveat". A card is marked "worth a look" (▲) only where the report or the step headline raises a flag, and "for information" (○) otherwise; no card says a section is fine. A card whose step has not ended says so; one whose step failed or is older than its input says that. A newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. -- **Leaves out** lists what the run does not assess. -- **Progress** is the step map, a step's detail, Found so far, the run-health card and the database re-check, as before. +**A person** (`/runs//`). The name, the sex the reads show against the declared one, the person's place in the family and the run state. Down the left, the contents: the ten sections with how many findings each level has, then the step timeline and the family. The page opens on the lead: a paragraph built from the findings (data-quality problems first, then the findings for a doctor named one by one, the medicine genes, the carrier genes, what is worth a look, and the mean depth; with no finding for a doctor it says so only when a clinical screen ran, and otherwise that the clinical screens have no result yet), the ledger of the five levels with a count each, the data-quality line, and Figure 1, the chromosomes with each finding at its place. Then the sections: -Each card links to its section of the report and to the files it names. The header search finds a gene or rsID across the results and opens the card that names it. `#q/` opens a tab, `#s/
` opens a card (the report's own anchors), `#progress` the step map. +1. **To act on**, 2. **Medicines**, 3. **For your family**, 4. **Worth a look**, 5. **Can I trust this?**: each finding of the level, with what it is, what to do, and in the margin its evidence, its caveat and its link to the report; then the cards of the report's sections that belong there. +6. **Origins** and 7. **Traits and scores**: the ancestry and trait cards. +8. **Assessed, no finding**: one line per section that ran and raised nothing, each opening its full card. +9. **What it cannot see**: what the run does not assess and the steps that did not run. +10. **Sources and files**: the database versions, the database re-check, and the report and its files. -- **The step map.** One box per step the run planned, drawn from `assets/steps.tsv`: a step sits right of the steps whose output it reads. The colour is the step's state from `bin/run_state.py` ([Where a Run Stands](run-state.md)): waiting, running, done, failed, skipped, not finished. Skipped steps stay on the map, greyed. -- **A step's detail.** Click a box: its state and reason, start and end, the usual hours, each process with its tasks waiting, running, done, from the cache or failed, and the headline of its sections with a link into the report. -- **Found so far.** One line per finished section, worded by the same code that writes the report, caveat included. No line ever says a section is fine. The ClinVar line, with no hit, is information, never an all-clear. +A card has the section's numbers, its rows (the first ones, then a button for all), its own sentences and its charts, and under it its caveat: the first sentence, then the rest behind "Full caveat". A section waiting for its step says "being written" or "waiting", one whose step failed says so, and a newer database release shows on the cards it would change. Some cards draw their numbers as well: the repeat expansions as one gauge per locus (the count against Stranger's normal and pathologic thresholds, with its 95% interval; an allele not sized from short reads is drawn hollow and never called normal), Lp(a) as the KIV-2 count with its 95% interval, CHIP as each call's VAF against its gene's detection limit, the copy numbers of SMN1, SMN2, HBA1, HBA2 and the RCCX module as dots, and the Y haplogroup as its path from the root, with the marker counts of each level. Each drawing has the same numbers as text beside it. APOE stays behind its warning in the traits card; the polygenic scores show only once they are placed against the ancestry panel. On a child's page in a family, a result to act on stays closed behind "Show the result" until someone opens it on purpose: its title, gene and variant stay out of the lead, the ledger, the chromosome map, the home page and its own page, and the card it comes from stays closed with it (its rows, numbers and drawing), in its section and in section 8. Each result opens on its own: opening one leaves every other result to act on closed, on the report and on the finding's page. A person in a family whose family report has not answered yet, or answered without listing them, is treated the same way, since the page cannot tell whether they are a child. + +While the run goes, the page opens on the run instead: a sentence on how far it is ("23 of 27 are done, about 8–13 hours remain"), the steps done as a big number and a bar, the time elapsed, the time to go from the usual step times, the retries, and the tasks running now against their usual time. Each section in the contents says whether it is written, being written or waiting. + +- **How the run went.** One line per step the run planned, on a time axis: a bar from its start to its end, or to now while it runs, with an outline to its usual hours. The colour is the step's state from `bin/run_state.py` ([Where a Run Stands](run-state.md)): waiting, running, done, failed, skipped, not finished. Beside it the tasks running now, a step's detail and the wire of what was found so far. +- **A step's detail.** Click a step: its state and reason, start and end, the usual hours, each process with its tasks waiting, running, done, from the cache or failed, and the headline of its sections with a link into the report. +- **Found so far.** One line per finished section, worded by the same code that writes the report, caveat included. The ClinVar line, with no hit, is information, never a clean result. - **The report.** Once written, `report.html` opens the run's `_report.html`, and the files it links to beside it. -- **The genome viewer.** `/runs//viewer/` shows the run's reads and variants in igv.js, as [`scripts/owngenome-view.sh`](viewer.md) does on one computer. The report's Viewer links and the charts' "See the reads" links open it at their position. +- **The genome viewer.** `/runs//viewer/` shows the run's reads and variants in igv.js, as [`scripts/owngenome-view.sh`](viewer.md) does on one computer. The report's Viewer links, the charts' "See the reads" links and a finding page's reads open it at their position. + +The header search finds a gene or rsID across the results and opens the card that names it. `#s` opens a section, `#s/
` a card (the report's own anchors), `#f/` a finding, `#q/` the section a report chapter falls in, `#progress` the timeline. + +**A finding** (`/runs//finding//`). One finding on a page of its own: its level and how many others share it, the title, the gene and variant, a plain explanation, and **What to do** as numbered steps. Then the evidence (its numbers, how common the change is on a log scale from one in a million to everyone, the caveat in the margin), the reads with a link to the genome viewer, the rest of the family, the related findings of the same gene or step, and the section of the report it comes from. The contents on the left lead back to the report and on to the next finding. The page is 404 unless the id is a finding of that run's `results.json` now. -**A family** (`/runs//`). The pedigree, drawn from `members.tsv` or `pedigree.tsv`, where each sequenced person with a run here opens their page and shows how many cards their report flags. Then the members, the analyses step 38 planned, each couple's carrier screen in full (genes where both carry a finding, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report, who is related how (step 38's relationship check beside the shared segments, as a grid of every pair, with ⚑ where the measured relationship differs from the declared one), and each child: the stretches inherited from each parent painted per chromosome (coloured by grandparent where step 38 could tell), the de novo candidates and the compound-het pairs, in trans first. Last, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and blood groups, one column each. On a phone the grid becomes a list of pairs. +**A family** (`/runs//`). A report in five chapters, opened by a lead: how many genomes are read together, whether the couple shares a carrier gene, whether the measured relationships match the tree, and how many children. I, the pedigree, drawn from `members.tsv` or `pedigree.tsv` in the members' colours, where each sequenced person with a run here opens their page and shows how many cards their report flags, beside the members and the analyses step 38 planned. II, who is related how: step 38's relationship check beside the shared segments, as a grid of every pair, with ⚑ where the measured relationship differs from the declared one. III, each couple's carrier screen in full (genes where both carry a finding, drawn as four children with the chance for each, X-linked genes, genes where one partner does), with the screen's caveat and a link to the couple report. IV, each child: the stretches inherited from each parent painted per chromosome (coloured by grandparent where step 38 could tell), the de novo candidates and the compound-het pairs, in trans first. V, the members side by side: their CYP2D6 calls, CPIC phenotypes, HLA types, haplogroups and blood groups, one column each. On a phone the grid becomes a list of pairs. -On a phone the tab column becomes a row that scrolls sideways and each table row becomes a short list of label and value. The pages follow the browser's dark or light setting. +On a phone each page is one column and each table row becomes a short list of label and value. On a person's report the contents give way to a tab bar at the bottom (To act, Medicines, Family, Trust, More); a finding page drops its contents (the header links back to the report), and a family page keeps its chapter list above the chapters. The pages are dark. ## The genome viewer @@ -73,9 +82,9 @@ Per run it serves the folder's BAM or CRAM, its VCF and its SV VCF, each with it The reads cross the network to the browser that logged in: only the byte ranges igv.js asks for, but they are the sample's reads. Off loopback, use TLS (`--tls-cert` and `--tls-key`, or a proxy that ends TLS); without it the password and the reads cross the network in clear. Opening the viewer writes one access-log row; the byte ranges igv.js reads write none. -`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. +`GET /` lists the runs: each folder under `GENOME_DIR` holding `logs/run_status.tsv`, `nextflow/` or `run_manifest.tsv`, and each run the site has messages for. The home page reads `/runs.json`: each run's progress and attention count, and each family with its members. A run with results also has `levels` (its findings counted by level), `top` (up to four findings to show first, data-quality problems first: id, level, title), `lead` (its lead paragraph as plain text) `lead_closed` (the same with the names of the findings to act on left out, for a child's tile) and `screened` (whether ClinVar, the clinical filter or CPSR has a result; without one the home page never says that no finding needs a doctor). While a run goes these come from the summary its page last read, as long as no step has finished and `summary.json` has not changed since that read, so the home page never shows less than the person page. While a family's members cannot be read, the home page keeps the findings to act on closed for every run that no readable family lists. -The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. +The person page reads `/runs//results.json` from `bin/owngenome_live_results.py`: `bin/live_results.py` turns the run's summary (its `summary.json` when step 24 wrote it after every finished step, else the step files read again when a step ends; a rewritten `summary.json` is read again too, and the page fetches the cards again) into the report's chapters, one card per section. Each card also has its `level`; the file adds `findings` (each with its id, level, section, title, plain text, gene, evidence, caveat, plan and related findings), `levels` (the counts), `journal` (the ten sections and the cards in each), `lead` (the lead paragraph as text pieces, the bold ones marked, and the names of the findings to act on marked `act`), `level_words` and `databases`. It computes nothing about genetics and holds only what the report prints. The family page reads `/runs//family.json` from the family plugin, and asks again every 30 seconds, so a member's progress and a new couple screen show without a reload. ## Privacy and what is stored @@ -167,8 +176,8 @@ WantedBy=multi-user.target The core files (`bin/owngenome_live.py`, `assets/live/index.html`, `home.html`, `family.html`, `core.js`, `live.js`, `person.js`, `home.js`, `family-page.js`, `live.css`, `login.html`) are meant to stay small. A new view adds two files instead of editing them: - `bin/owngenome_live_.py` with `register(app)`. At start the server imports every such file, sorted, and calls it. `app.route(method, pattern, fn, auth=True)` adds a page or an endpoint (behind the login unless `auth=False`), `app.run_file(glob)` lets the browser fetch more files of a run folder, `app.report_filter(fn)` rewrites the served report, `app.state_hook(name, fn)` adds `plugins[name]` to `state.json`, `app.run_kind(fn)` lists more runs, `app.add_argument(...)` adds a flag, `app.log(...)` writes an access-log row, and `app.config` holds the parsed flags. -- `assets/live/plugins/.js`. The run page loads every script in that folder after `live.js`; it calls `window.PGP.register({name, onState, onEvent, panel})`. `onState(state)` gets each new `state.json`, `onEvent(message)` each message from the stream, and `panel(element)` gets a card of its own in the Progress tab, or in the element whose id `slot` names (the search box sits in the header this way). A family's page loads the same scripts without `live.js`, so a plugin returns early when `window.PGP` is missing. +- `assets/live/plugins/.js`. The run page loads every script in that folder after `live.js`; it calls `window.PGP.register({name, onState, onEvent, panel})`. `onState(state)` gets each new `state.json`, `onEvent(message)` each message from the stream, and `panel(element)` gets a card of its own beside the run timeline, or in the element whose id `slot` names (the search box sits in the header this way). A family's page loads the same scripts without `live.js`, so a plugin returns early when `window.PGP` is missing. The page has a replay mode for a demo with no server: `` reads `frames.json` (`{"interval_ms": 1000, "frames": [{"t": ..., "state": }]}`) and plays it with a play button and a slider, through the same drawing code. -`tests/test_owngenome_live.py` checks the server, the login, the stored messages, the plugin interface and the page in a headless Chrome; `tests/test_live_results.py` checks `results.json` (every card's caveat word for word, no wording that reads as an all-clear, `results.json` behind the login); `tests/test_live_demo.py` builds the demo and checks every page, phone width included; `tests/test_live_post.sh` and `tests/fake-docker/run-all-live.sh` check what `run-all.sh` posts. +`tests/test_owngenome_live.py` checks the server, the login, the stored messages, the plugin interface and the page in a headless Chrome; `tests/test_live_results.py` checks `results.json` (every card's caveat word for word, no wording that reads as an all-clear, `results.json` behind the login); `tests/test_live_journal.py` checks the levels, the lead paragraph and the finding page's route; `tests/test_live_demo.py` builds the demo and checks every page, phone width included; `tests/test_live_post.sh` and `tests/fake-docker/run-all-live.sh` check what `run-all.sh` posts. diff --git a/docs/recheck.md b/docs/recheck.md index a9c8de84..4fef29fc 100644 --- a/docs/recheck.md +++ b/docs/recheck.md @@ -91,7 +91,7 @@ python3 bin/owngenome_recheck.py --launch-dir /path/to/launch/dir ## In the reports and on the live site -The next reports read `recheck/state.json`: both show one "Database re-check" line in the run facts. A check older than the sample's `run_manifest.tsv` describes an earlier run, so the reports leave it out until the next check. When the sample has a previous run in `history/` ([the delta report](delta-report.md)), a section whose results changed since it carries a badge such as "New since 2026-10-01T09:15:00Z", in both reports, and the delta report heads its changes the same way. The live site's re-check plugin (`bin/owngenome_live_recheck.py`, `assets/live/plugins/recheck.js`) shows the same line as a banner, puts a badge (↻) such as "newer ClinVar available" in the header of every result card of a named step on the person page, and marks each named step on the Progress tab's step map with a dot whose tooltip says the same or "redone 2026-10-05". +The next reports read `recheck/state.json`: both show one "Database re-check" line in the run facts. A check older than the sample's `run_manifest.tsv` describes an earlier run, so the reports leave it out until the next check. When the sample has a previous run in `history/` ([the delta report](delta-report.md)), a section whose results changed since it carries a badge such as "New since 2026-10-01T09:15:00Z", in both reports, and the delta report heads its changes the same way. The live site's re-check plugin (`bin/owngenome_live_recheck.py`, `assets/live/plugins/recheck.js`) shows the same line as a banner, puts a badge (↻) such as "newer ClinVar available" in the header of every result card of a named step on the person page, and marks each named step on the run timeline with a dot at the end of its line, whose tooltip says the same or "redone 2026-10-05". The Nextflow `HTML_REPORT` process sees no `history/` folder, so its report has no "New since" badge. diff --git a/scripts/ci/e2e-case-paths.tsv b/scripts/ci/e2e-case-paths.tsv index 32f1f190..563ae2ed 100644 --- a/scripts/ci/e2e-case-paths.tsv +++ b/scripts/ci/e2e-case-paths.tsv @@ -103,7 +103,7 @@ hba-short-read-accuracy-1-nf-report assets/acmg_sf.tsv assets/callability_genes. hla-second-caller-1-bash assets/pgx_genes.tsv assets/stub/db/hla.dat assets/stub/db/kir.dat bin/cn_loci.tsv bin/copy_number.py bin/cyp2d6_depth_check.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/pgx_outside_calls.py bin/render_report.py scripts/08-hla-typing.sh scripts/24-html-report.sh scripts/32-pypgx.sh scripts/36-pgx-consensus.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh hla-second-caller-2-nextflow assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/mito_confirmed.tsv assets/pgx_genes.tsv assets/stub/db/hla.dat assets/stub/db/kir.dat assets/stub/sample.vcf.gz bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/cyp2d6_depth_check.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/pgx_mito_merge.py bin/pgx_outside_calls.py bin/pgx_refblock_fill.py bin/render_report.py bin/results_index.py modules/local/hla_typing/* modules/local/html_report/* modules/local/pgx_consensus/* modules/local/pharmcat/* modules/local/t1k_build/* modules/local/vcf_precheck/* scripts/07-pharmacogenomics.sh scripts/08-hla-typing.sh scripts/21-cyrius.sh scripts/24-html-report.sh scripts/32-pypgx.sh scripts/36-pgx-consensus.sh scripts/ci/fake-docker/lib.sh scripts/cyrius-constraints.txt scripts/setup.sh scripts/validate-setup.sh ibd-segments-1 assets/families.example.tsv assets/par_grch38.bed assets/pedigree.example.tsv assets/stub/pedigree.tsv bin/family_report.py bin/family_segments.py bin/ibd_report.py scripts/38-family.sh scripts/ci/fake-docker/lib.sh scripts/setup.sh -live-server-1-weblog assets/live/home.html assets/live/index.html assets/live/live.css assets/live/login.html assets/viewer/index.html bin/live_results.py bin/owngenome_live.py bin/owngenome_live_results.py bin/render_report.py bin/run_state.py modules/local/roh/* modules/local/vcf_precheck/* scripts/11-roh-analysis.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh +live-server-1-weblog assets/live/finding.html assets/live/home.html assets/live/index.html assets/live/live.css assets/live/login.html assets/viewer/index.html bin/live_results.py bin/owngenome_live.py bin/owngenome_live_results.py bin/render_report.py bin/run_state.py modules/local/roh/* modules/local/vcf_precheck/* scripts/11-roh-analysis.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh lpa-1 assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/mito_confirmed.tsv assets/par_grch38.bed assets/stub/sample.vcf.gz bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/render_report.py bin/results_index.py modules/local/deepvariant/* modules/local/html_report/* modules/local/lpa/* modules/local/vcf_precheck/* scripts/24-html-report.sh scripts/46-lpa.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh mei-1 assets/mei_sites.tsv assets/par_grch38.bed assets/sv_rule_regions.tsv bin/annotsv_postfilter.py bin/cnvpytor_to_vcf.py bin/collect_summary.py bin/family_report.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 51b2eb0b..a7b0e01e 100644 --- a/tests/demo/build_live_demo.py +++ b/tests/demo/build_live_demo.py @@ -13,6 +13,8 @@ runs/DEMO-001/results.json the person's results (bin/live_results.py), as the results plugin serves them at the end of the run; each card shows once the replay reaches the end of its steps + runs/DEMO-001/finding// one page per finding (assets/live/finding.html), as the + results plugin's finding route serves it runs/DEMO-001/... the files the plugins point at (the charts, the search index, the re-check), the report as report.html and the files it links to @@ -363,6 +365,16 @@ def person_site(out, root, name, plugin_js, frames, results, autoplay=False): if os.path.isfile(p): os.makedirs(os.path.dirname(os.path.join(sd, dst)), exist_ok=True) shutil.copy(p, os.path.join(sd, dst)) + # One page per finding, as the server's /runs//finding// route serves it. + with open(os.path.join(ASSETS, "finding.html")) as f: + tpl = f.read() + for fd in results.get("findings") or []: + fdir = os.path.join(sd, "finding", fd["id"]) + os.makedirs(fdir) + with open(os.path.join(fdir, "index.html"), "w") as f: + f.write(with_csp(tpl.replace("{{assets}}", "../../../../assets").replace("{{run}}", html.escape(name)) + .replace("{{finding}}", html.escape(fd["id"])).replace("{{mode}}", "replay") + .replace('href="/"', 'href="../../../../"'))) os.makedirs(os.path.join(sd, "viewer")) with open(os.path.join(sd, "viewer", "index.html"), "w") as f: f.write(plain_page("Genome viewer: not in the demo", @@ -375,7 +387,7 @@ def write_site(out, root, frames, fam): if os.path.exists(out): shutil.rmtree(out) shutil.copytree(ASSETS, os.path.join(out, "assets"), - ignore=shutil.ignore_patterns("index.html", "login.html", "home.html", "family.html")) + ignore=shutil.ignore_patterns(*owngenome_live.PAGES)) plugin_dir = os.path.join(ASSETS, "plugins") plugin_js = sorted(f for f in os.listdir(plugin_dir) if owngenome_live.ASSET_RE.match(f) and f.endswith(".js")) \ if os.path.isdir(plugin_dir) else [] diff --git a/tests/demo/render_pictures.py b/tests/demo/render_pictures.py index 795eccc1..acb72a2d 100755 --- a/tests/demo/render_pictures.py +++ b/tests/demo/render_pictures.py @@ -123,7 +123,7 @@ def live_picture(out): # all 'self', so the picture is the same. page = browser.new_page(viewport={"width": 1280, "height": 1000}, device_scale_factor=1, color_scheme="dark", bypass_csp=True) - page.goto(f"http://127.0.0.1:{srv.server_address[1]}/runs/{SAMPLE}/#q/clinical") + page.goto(f"http://127.0.0.1:{srv.server_address[1]}/runs/{SAMPLE}/") page.wait_for_function("document.documentElement.dataset.pgpStatus === 'ready'") # The last moment: the run ended, the charts are drawn, the re-check is in. page.evaluate("() => { const s = document.getElementById('replay-slider'); s.value = s.max; " diff --git a/tests/test_live_demo.py b/tests/test_live_demo.py index e3604f9b..a8c57dc5 100644 --- a/tests/test_live_demo.py +++ b/tests/test_live_demo.py @@ -21,18 +21,24 @@ static host serves it (no policy header): the home page's people and family, the replay to its last frame, the charts, a search, the re-check banner, the run-health card, the family link and its pedigree, the viewer - page, and no policy violation; the person page's cards (the doctor tab - with the ClinVar card, its caveat and its chart), the tab row moved with - the arrow keys, #s/clinvar focusing its card; the family page with a + page, and no policy violation; the person page's ten sections, its + contents, lead and ledger, #s/clinvar focusing the ClinVar card with its + caveat, chart and flag, a finding's own page with its plan and evidence; + the family page with a clickable pedigree and no step map or report link; the widgets of the partner's and the son's pages (the repeat gauge, the Lp(a) interval, the CHIP VAF, the copy-number dots, the Y path) and the AnnotSV and MoChA marks on their genome charts; the family page's relatedness grid, the son's painted stretches (one mark per row of his segments file), his de novo and compound-het tables and the members side by side; and at a phone's - width (390 px) no page scrolls sideways. + width (390 px) no page scrolls sideways (every section open, the finding + page too) and the person page has its bottom tab bar; on the son's page a + two planted CPSR results to act on name their genes and variants nowhere + (their card included) until "Show the result" is clicked, and each click + opens one result, there and on the finding's own page. Checks 1, 2, 3, 5, the widgets (person.js without its widget line), the family depth (family.json -with the son's stretches emptied) and the phone width also run against a planted mistake and must +with the son's stretches emptied), the phone width and the son's closed result (person.js whose cards ignore +the veil) also run against a planted mistake and must fail there (2: a build with one plugin's state hook dropped): a check that cannot fail is no check. Run: python3 tests/test_live_demo.py @@ -52,6 +58,7 @@ sys.path.insert(0, os.path.join(REPO, "tests", "demo")) sys.path.insert(0, os.path.join(REPO, "tests")) import build_live_demo # noqa: E402 +import live_results # noqa: E402 import make_demo_events # noqa: E402 import owngenome_live # noqa: E402 import run_state # noqa: E402 @@ -273,6 +280,7 @@ def browser_checks(out): widget_checks(b, base, out) family_depth_checks(b, base, out) phone_checks(b, base) + veil_checks(b, base, out) finally: b.close() srv.shutdown() @@ -280,21 +288,31 @@ def browser_checks(out): def person_checks(b, base): - """The person page at the end of the run: cards, keyboard, a section link; the family page's own view.""" - b.eval("location.hash = '#q/clinical'; true") - n = b.wait("document.documentElement.dataset.pgpTab === 'clinical' && document.documentElement.dataset.pgpResults") - card = b.eval("var c = document.getElementById('card-clinvar'); c && [!!c.querySelector('.caveat .caveat-lead'), " - "!!c.querySelector('figure[data-chart]'), c.classList.contains('attn')].join(' ')") - check("browser: the doctor tab shows its cards; ClinVar's has its caveat, its chart and its flag", - n and int(n) > 3 and card == "true true true", (n, card)) - b.eval("var t = document.querySelector('#rail [aria-selected=true]'); t.focus(); " - "t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); true") - tab = b.wait("document.documentElement.dataset.pgpTab !== 'clinical' && document.documentElement.dataset.pgpTab") - check("browser: the arrow key moves to the next chapter", tab == "carrier", tab) + """The person page at the end of the run: the ten sections, the lead, the contents, a section link, a finding + page; the family page's own view.""" + got = b.wait("document.documentElement.dataset.pgpResults && JSON.stringify([" + "document.querySelectorAll('main section.sec[data-journal]').length, " + "document.querySelectorAll('#rail [data-toc]').length, " + "(document.querySelector('#lead-slot .lede') || {textContent: ''}).textContent.length > 40, " + "document.querySelectorAll('#lead-slot .ledger a[data-level]').length])") + check("browser: the person page has its ten sections, the contents linking each, a lead paragraph and the ledger", + got == "[10,10,true,5]", got) b.eval("location.hash = '#s/clinvar'; true") - focus = b.wait("document.documentElement.dataset.pgpTab === 'clinical' && document.activeElement && " - "document.activeElement.id") - check("browser: #s/clinvar opens the doctor tab and focuses the ClinVar card", focus == "card-clinvar", focus) + focus = b.wait("document.activeElement && document.activeElement.id === 'card-clinvar' && 'card-clinvar'") + card = b.eval("var c = document.getElementById('card-clinvar'); c && [!!c.querySelector('.caveat .caveat-lead'), " + "!!c.querySelector('figure[data-chart]'), c.classList.contains('attn'), " + "c.getBoundingClientRect().height > 0].join(' ')") + check("browser: #s/clinvar focuses the ClinVar card, shown, with its caveat, its chart and its flag", + focus == "card-clinvar" and card == "true true true true", (focus, card)) + link = b.eval("var a = document.querySelector('[data-finding] h3 a[href^=\"finding/\"]'); a && a.getAttribute('href')") + check("browser: a finding links its own page", bool(link), link) + if link: + b.go(f"{base}/runs/{S}/{link}") + page = b.wait("document.documentElement.dataset.pgpStatus === 'ready' && JSON.stringify([" + "!!document.querySelector('h1.h-display'), document.querySelectorAll('#plan li').length > 0, " + "!!document.getElementById('evidence'), document.documentElement.dataset.pgpBlocked || ''])") + check("browser: the finding page shows its title, its plan and its evidence, and the policy blocks nothing", + page == '[true,true,true,""]', (link, page)) b.go(f"{base}/runs/{FAM}/") fam = b.wait("document.documentElement.dataset.pgpStatus === 'ready' && [!document.getElementById('map'), " "!document.getElementById('report-link'), (document.querySelector('svg.pedigree-svg a') || {" @@ -303,24 +321,24 @@ def person_checks(b, base): fam == f"true true ../../runs/{S}/", fam) -# The widgets: (person, tab, kind, the marks it must draw) on the demo's other people. -WIDGETS = [("DEMO-002", "clinical", "str", "[data-widget=str] .w-row", 8), - ("DEMO-002", "clinical", "ci", "[data-widget=ci] .w-tick", 1), - ("DEMO-002", "clinical", "vaf", "[data-widget=vaf] .w-tick.w-call", 1), - ("DEMO-003", "carrier", "copies", "[data-widget=copies] li", 5), - ("DEMO-003", "ancestry", "ypath", "[data-widget=ypath] li", 5), - ("DEMO-002", "clinical", "MoChA bar", "figure[data-chart=genome] rect.mca", 1), - ("DEMO-003", "clinical", "AnnotSV span", "figure[data-chart=genome] rect.sv", 1)] +# The widgets: (person, card, kind, the marks it must draw) on the demo's other people. +WIDGETS = [("DEMO-002", "expansions", "str", "[data-widget=str] .w-row", 8), + ("DEMO-002", "lpa", "ci", "[data-widget=ci] .w-tick", 1), + ("DEMO-002", "chip", "vaf", "[data-widget=vaf] .w-tick.w-call", 1), + ("DEMO-003", "paralogs", "copies", "[data-widget=copies] li", 5), + ("DEMO-003", "y-chromosome", "ypath", "[data-widget=ypath] li", 5), + ("DEMO-002", "clinvar", "MoChA bar", "figure[data-chart=genome] rect.mca", 1), + ("DEMO-003", "clinvar", "AnnotSV span", "figure[data-chart=genome] rect.sv", 1)] def widget_counts(b, base): got = {} - for person, tab, kind, sel, _ in WIDGETS: + for person, cid, kind, sel, _ in WIDGETS: b.go("about:blank") # a new document each time, so the wait never reads the page before b.wait("location.href === 'about:blank'") - b.go(f"{base}/runs/{person}/#q/{tab}") - b.wait("document.documentElement.dataset.pgpResults && document.documentElement.dataset.pgpTab === %s" - % json.dumps(tab)) + b.go(f"{base}/runs/{person}/#s/{cid}") + b.wait("document.documentElement.dataset.pgpResults && !!document.getElementById(%s) && 'drawn'" + % json.dumps("card-" + cid)) if kind in ("MoChA bar", "AnnotSV span"): b.wait("document.documentElement.dataset.pgpCharts") time.sleep(0.5) @@ -389,6 +407,99 @@ def family_depth_checks(b, base, out): f.write(keep) +# Two results to act on planted on the son's page: invented genes and variants in two CPSR rows. +VEIL = (("GENEQ", "c.77del"), ("GENER", "c.88dup")) + + +def veil_text(b, base): + """The son's pages: [the person page's text before any click, after one click on the CPSR card's 'Show the result', + the first finding's own page after its 'Show the result' click]. textContent reads closed details too.""" + b.go("about:blank") + b.wait("location.href === 'about:blank'") + b.go(f"{base}/runs/{OTHERS[1]}/") + b.wait("document.documentElement.dataset.pgpResults && document.documentElement.dataset.pgpStatus === 'ready' " + "&& document.querySelector('.veil') && 'drawn'") + before = b.eval("document.body.textContent") + # The card's own button: a card holding two results opens one per click, as each finding's button does. + b.eval("document.querySelector('#card-cpsr .veil button').click(); true") + time.sleep(0.5) + after = b.wait("document.body.textContent") + fid = b.eval("var a = document.querySelector('[data-finding] h3 a[href*=\"finding/cpsr-\"]'); a && a.getAttribute('href')") + page = "" + if fid: + b.go(f"{base}/runs/{OTHERS[1]}/{fid}") + b.wait("document.documentElement.dataset.pgpStatus === 'ready' && document.querySelector('.veil') && 'closed'") + b.eval("document.querySelector('.veil button').click(); true") + page = b.wait("!document.querySelector('.veil') && document.body.textContent") + return before, after, page + + +def veil_problems(before, after, page): + (g1, v1), (g2, v2) = VEIL + out = [f"{w} shown before any click" for w in (g1, v1, g2, v2) if w in (before or "")] + shown = [w for w in (g1, v1) if w in (after or "")] or [w for w in (g2, v2) if w in (after or "")] + if len(shown) != 2: + out.append(f"the first click did not open one result: {shown}") + # The other result stays closed after one click, on the person page and on the opened finding's page. + other = (g2, v2) if shown and shown[0] == g1 else (g1, v1) + out += [f"{w} opened by the click on another result" for w in other if w in (after or "")] + if not page: + out.append("no finding page of a planted result") + else: + mine = [w for pair in VEIL for w in pair if w in page] + if len(mine) != 2: + out.append(f"the finding page opened shows {mine}, want only its own gene and variant") + return out + + +def veil_checks(b, base, out): + """On a child's page each result to act on stays closed everywhere, its own section's card too, until it + is opened; opening one opens only that one, on the person page and on its own page.""" + rj = os.path.join(out, "runs", OTHERS[1], "results.json") + js = os.path.join(out, "assets", "person.js") + with open(rj) as f: + keep = f.read() + with open(js) as f: + keep_js = f.read() + made = [] + try: + with open(os.path.join(os.path.dirname(out), "root", OTHERS[1], "summary.json")) as f: + summary = json.load(f) + summary.setdefault("sections", {})["cpsr"] = {"state": "ok", "data": {"call_floor_dp": 10, "low_confidence": 0, "plp": [ + {"gene": g, "variant": v, "final": "Pathogenic", "clinvar": "Pathogenic"} for g, v in VEIL]}} + res = live_results.build(OTHERS[1], summary) + cpsr = [c for ch in res["chapters"] for c in ch["sections"] if c["id"] == "cpsr"] + check("browser: the planted CPSR card has its table, so the closed check below has rows to hide", + bool(cpsr and cpsr[0]["table"] and cpsr[0]["section"] == 1), cpsr) + with open(rj, "w") as f: + json.dump(res, f) + # Their finding pages, as build_live_demo.person_site writes one per finding: an existing page, its id swapped. + fdirs = os.path.join(out, "runs", OTHERS[1], "finding") + model = sorted(os.listdir(fdirs))[0] + with open(os.path.join(fdirs, model, "index.html")) as f: + tpl = f.read() + for fd in res["findings"]: + if fd["card"] == "cpsr": + os.makedirs(os.path.join(fdirs, fd["id"])) + made.append(os.path.join(fdirs, fd["id"])) + with open(os.path.join(fdirs, fd["id"], "index.html"), "w") as f: + f.write(tpl.replace(f'data-finding="{model}"', f'data-finding="{fd["id"]}"')) + bad = veil_problems(*veil_text(b, base)) + check("browser: on the son's page two planted results to act on name their genes and variants nowhere " + "until 'Show the result' is clicked, and each click opens one, on the person page and the finding page", + not bad, bad) + with open(js, "w") as f: + f.write(keep_js.replace("} else if (closedFs.length) {", "} else if (false) {")) + control("person.js whose cards ignore the veil", not veil_problems(*veil_text(b, base))) + finally: + with open(rj, "w") as f: + f.write(keep) + with open(js, "w") as f: + f.write(keep_js) + for d in made: + shutil.rmtree(d, ignore_errors=True) + + def too_wide(b): return b.eval("document.documentElement.scrollWidth > window.innerWidth ? " "document.documentElement.scrollWidth + ' > ' + window.innerWidth : ''") @@ -401,9 +512,11 @@ def phone_checks(b, base): try: wide = {} for name, path, ready in (("home", "/", "document.documentElement.dataset.pgpStatus === 'ready'"), - ("person", f"/runs/{S}/#q/clinical", "document.documentElement.dataset.pgpResults"), + ("person", f"/runs/{S}/", "document.documentElement.dataset.pgpResults"), ("family", f"/runs/{FAM}/", "document.documentElement.dataset.pgpStatus === 'ready'"), - ("partner", f"/runs/{OTHERS[0]}/#q/clinical", "document.documentElement.dataset.pgpResults")): + ("partner", f"/runs/{OTHERS[0]}/", "document.documentElement.dataset.pgpResults"), + ("finding", f"/runs/{OTHERS[0]}/finding/str-fmr1/", + "document.documentElement.dataset.pgpStatus === 'ready'")): b.go(base + path) b.wait(ready) if name == "person": @@ -411,15 +524,12 @@ def phone_checks(b, base): "s.dispatchEvent(new Event('input')); true") b.wait("document.documentElement.dataset.pgpFrame === document.getElementById('replay-slider').max") time.sleep(1) - # Every tab of the person page, not only the one the link opens. - tabs = b.eval("Array.prototype.map.call(document.querySelectorAll('[role=tab][data-tab]'), " - "function (t) { return t.getAttribute('data-tab'); }).join(' ')") or "" - for tab in tabs.split(): - b.eval(f"document.querySelector('[role=tab][data-tab=\"{tab}\"]').click(); true") - time.sleep(0.3) - if too_wide(b): - wide[f"person {tab}"] = too_wide(b) - wide["person tabs"] = "" if len(tabs.split()) >= 3 else f"only {tabs!r}" + # Every section open: the cards folded into section 8 too. + b.eval("Array.prototype.forEach.call(document.querySelectorAll('main details'), " + "function (d) { d.open = true; }); true") + time.sleep(0.3) + tabs = b.eval("getComputedStyle(document.getElementById('tabbar')).display") + wide["person tab bar"] = "" if tabs == "grid" else f"tab bar display {tabs!r}" wide[name] = too_wide(b) check("browser: at 390 px no page scrolls sideways", not any(wide.values()), wide) b.eval("var d = document.createElement('div'); d.style.width = '900px'; d.style.height = '4px'; " diff --git a/tests/test_live_journal.py b/tests/test_live_journal.py new file mode 100644 index 00000000..136fa560 --- /dev/null +++ b/tests/test_live_journal.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""The Journal layer of a person's results (bin/live_results.py) and the finding page's route +(bin/owngenome_live_results.py). + +Checks: + 1. the level mapping, on a small made-up summary: CPSR's pathogenic row is to act on, a ClinVar hit + outside the carrier list is worth a look, a carrier-list row is for family planning (two changed + copies of a carrier gene, and a full-mutation repeat, are to act on), a CPIC gene + with changed function is for the prescriber, a contamination warning is data quality; each card + takes its highest finding's level, the quality chapter is data quality, a failed card has none; + every finding id is a safe path part (live_results.FINDING_ID) and sits in its level's section; + 2. the lead paragraph: with a finding to act on it says how many go to a doctor and names each one + (marked, so a child's page can leave the names out), a data-quality problem comes first, and with no finding it says none needs a doctor and points at + sections 8 and 9, never an all-clear (live_results.FORBIDDEN); with no clinical screen run it says the + clinical screens have no result yet; + 3. through the real server: /runs//finding// sends a browser with no session to the login and + back, answers the finding page + with the run and the id filled in and the server's Content-Security-Policy for an id of the run's + findings, and a 404 for an id that is not one, for a path-like id and for a run with no such page; + 4. /runs.json of a running run counts from the summary its page last read only while that read is + current (same steps done, same summary.json), and says whether a clinical screen has a result. +Each check runs again on a planted mistake and must fail there: 1 with the CPIC rule's level swapped +to review, 2 with the act findings left out of the lead and with their names unmarked and with the +old lead on a run with no clinical screen, 3 with the route answering any id, 4 with a current read. A check +that cannot fail is no check. + +Run: python3 tests/test_live_journal.py +""" +import contextlib +import copy +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import urllib.parse + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "bin")) +sys.path.insert(0, os.path.join(REPO, "tests")) +import collect_summary # noqa: E402 +import live_results # noqa: E402 +import owngenome_live # noqa: E402 + +SAMPLE = "DEMO-001" +PASSWORD = "synthetic test password 9" +FAILS = [] + + +def check(desc, ok, detail=""): + print(("[PASS] " if ok else "[FAIL] ") + desc + ("" if ok else f" -- {str(detail)[:800]}")) + if not ok: + FAILS.append(desc) + + +def control(desc, still_passes): + check(f"control: {desc}", not still_passes, "the check passed on the planted mistake") + + +def ok(data): + return {"state": "ok", "data": data} + + +# A made-up summary: invented genes and positions, one row per rule under test. +SUMMARY = {"sections": { + "cpsr": ok({"plp": [{"gene": "GENEA", "variant": "c.100del", "final": "Pathogenic", "clinvar": "Pathogenic"}]}), + "clinvar": ok({"hits": [{"gene": "GENEB", "chrom": "chr2", "pos": 1000, "ref": "C", "alt": "T", + "significance": "Pathogenic", "stars": 2, "genotype": "het", "af": 0.0001}]}), + "carrier": ok({"carriers": [{"gene": "GENEC", "variant": "c.5A>G", "condition": "Invented condition", + "significance": "Pathogenic", "stars": 2, "inheritance": "AR", "genotype": "het"}, + {"gene": "GENED", "variant": "c.9C>T", "condition": "Another invented condition", + "significance": "Pathogenic", "stars": 2, "inheritance": "AR", "genotype": "hom"}]}), + "expansions": ok({"loci": [{"locus": "FMR1", "repeat_count": "/".join(("30", "250")), "status": "pre_mutation,full_mutation", + "ci": "29-31/240-260", "allele_sizing": "spanning/flanking"}]}), + "cpic": ok({"genes": [{"gene": "CYP2C19", "diplotype": "*2/*2", "phenotype": "Poor Metabolizer", + "status": "non-normal"}, + {"gene": "CYP2C9", "diplotype": "*1/*1", "phenotype": "Normal Metabolizer", "status": "normal"}]}), + "sample_qc": ok({"contamination": "warn", "freemix": 0.05}), + "coverage": ok({"mean_depth": 31.2}), + "lpa": {"state": "failed", "note": "invented failure", "data": {}}, +}} + +WANT = {"cpsr": "act", "clinvar": "review", "carrier-genec": "carrier", "carrier-gened": "act", "str-fmr1": "act", + "pgx": "medicine", "contamination": "trust"} + + +def level_problems(res): + out = [] + found = res["findings"] + for prefix, lv in WANT.items(): + got = [f["level"] for f in found if f["id"].startswith(prefix)] + if got != [lv]: + out.append(f"{prefix}: {got}, want [{lv}]") + fmr1 = [f for f in found if f["id"] == "str-fmr1"] + if fmr1 and "full-mutation" not in fmr1[0]["title"]: + out.append(f"a full-mutation repeat titled {fmr1[0]['title']!r}") + if any(f["id"].startswith("pgx-cyp2c9") for f in found): + out.append("a gene with a normal phenotype became a finding") + for f in found: + if not live_results.FINDING_ID.match(f["id"]): + out.append(f"unsafe id {f['id']!r}") + if f["section"] != live_results.LEVEL_SECTION[f["level"]]: + out.append(f"{f['id']} in section {f['section']}") + cards = {c["id"]: c for ch in res["chapters"] for c in ch["sections"]} + for cid, lv in (("cpsr", "act"), ("cpic", "medicine"), ("carrier-list", "act"), ("clinvar", "review"), + ("qc", "trust"), ("lpa", None)): + if cards[cid].get("level") != lv: + out.append(f"card {cid}: {cards[cid].get('level')}, want {lv}") + return out + + +def lead_text(segs): + return "".join(x[0] for x in segs) + + +def lead_problems(res, screened=True): + out = [] + text = lead_text(res["lead"]) + acts = [f for f in res["findings"] if f["level"] == "act"] + trust = [f for f in res["findings"] if f["level"] == "trust"] + if acts: + n = len(acts) + said = re.search(r"\b(\w+) findings? should go to a doctor", text) + words = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9} + if not said or words.get(said.group(1).lower(), int(said.group(1)) if said.group(1).isdigit() else None) != n: + out.append(f"no count of findings for a doctor: {text[:300]}") + out += [f"lead does not name {f['title']!r}" for f in acts if f["title"] not in text] + elif screened and ("needs a doctor" not in text or "sections 8 and 9" not in text): + out.append(f"no-finding lead: {text[:300]}") + elif not screened and ("needs a doctor" in text or "clinical screens have no result yet" not in text): + out.append(f"no clinical screen ran, yet the lead reads: {text[:300]}") + if trust and not text.startswith("Read the data-quality section first"): + out.append("a data-quality problem is not first") + if live_results.FORBIDDEN.search(text): + out.append(f"all-clear wording: {text[:300]}") + # A child's tile: the lead without the names of the findings to act on. + closed = "".join(x[0] if x[2:] != ["act"] else "" for x in res["lead"]) + out += [f"the closed lead still names {f['title']!r}" for f in acts if f["title"] in closed] + return out + + +def request(port, method, path, body=None, headers=None): + import http.client + c = http.client.HTTPConnection("127.0.0.1", port, timeout=30) + h = {"Host": f"127.0.0.1:{port}"} + h.update(headers or {}) + c.request(method, path, body=body, headers=h) + r = c.getresponse() + data = r.read() + hdr = {k.lower(): v for k, v in r.getheaders()} + c.close() + return r.status, hdr, data + + +def serve(root, state, pwfile): + app = owngenome_live.App() + with contextlib.redirect_stderr(io.StringIO()): + live, web, ingest = owngenome_live.start(["--root", root, "--state-dir", state, "--port", "0", "--ingest-port", "0", + "--password-file", pwfile, "--quiet"], app) + threading.Thread(target=web.serve_forever, daemon=True).start() + return live, web, ingest + + +def stop(srv): + live, web, ingest = srv + live.stopping = True + web.shutdown() + web.server_close() + ingest.server_close() + + +def route_problems(port, fid): + out = [] + path = f"/runs/{SAMPLE}/finding/{urllib.parse.quote(fid)}/" + code, h, _ = request(port, "GET", path) + if code != 303 or h.get("location") != "/login?next=" + urllib.parse.quote(path): + out.append(f"finding page without a session: {code} {h.get('location')}") + code, h, _ = request(port, "POST", "/login", urllib.parse.urlencode({"password": PASSWORD, "next": "/"}), + {"Content-Type": "application/x-www-form-urlencoded"}) + m = re.match(r"owngenome_live=([^;]+)", h.get("set-cookie", "")) + if not m: + return out + [f"login {code}"] + ck = {"Cookie": f"owngenome_live={m.group(1)}"} + code, h, body = request(port, "GET", path, headers=ck) + page = body.decode("utf-8", "replace") + if code != 200 or f'data-finding="{fid}"' not in page or f'data-run="{SAMPLE}"' not in page \ + or "{{" in page or "script-src 'self'" not in h.get("content-security-policy", ""): + out.append(f"the finding page: {code} {h.get('content-security-policy')} {page[:200]}") + for bad in ("not-a-finding-of-this-run", "..%2F..%2Fsummary.json", "UPPER"): + code, _, _ = request(port, "GET", f"/runs/{SAMPLE}/finding/{bad}/", headers=ck) + if code != 404: + out.append(f"finding {bad}: {code}, want 404") + code, _, _ = request(port, "GET", f"/runs/NO-SUCH-RUN/finding/{urllib.parse.quote(fid)}/", headers=ck) + if code != 404: + out.append(f"a run that does not exist: {code}") + return out + + +def main(): + work = tempfile.mkdtemp(prefix="test_live_journal.") + srvs = [] + try: + # 1. + res = live_results.build("MADE-UP", copy.deepcopy(SUMMARY)) + bad = level_problems(res) + check("1. each rule gives its level, each card its highest finding's level, each id is safe", not bad, bad) + rows = live_results.finding_rows + + def swapped(summary): + return [dict(f, level="review") if f["id"].startswith("pgx") else f for f in rows(summary)] + live_results.finding_rows = swapped + try: + control("the CPIC rule's level swapped to review", + not level_problems(live_results.build("MADE-UP", copy.deepcopy(SUMMARY)))) + finally: + live_results.finding_rows = rows + + # 2. + bad = lead_problems(res) + check("2. the lead counts and names the findings for a doctor, data quality first", not bad, bad) + calm = copy.deepcopy(SUMMARY) + for k in ("cpsr", "sample_qc", "carrier", "cpic", "expansions"): + calm["sections"].pop(k) + calm["sections"]["clinvar"] = ok({"hits": []}) + res2 = live_results.build("MADE-UP", calm) + bad = lead_problems(res2) + check("2. with nothing to act on the lead says none needs a doctor and points at sections 8 and 9", not bad, + (bad, lead_text(res2["lead"]))) + # Only the medicine and quality steps finished: a medicine finding, no clinical screen. The lead must + # not say nothing needs a doctor. + bare = {"sections": {k: copy.deepcopy(SUMMARY["sections"][k]) for k in ("cpic", "sample_qc", "coverage") + if k in SUMMARY["sections"]}} + res3 = live_results.build("MADE-UP", bare) + bad = lead_problems(res3, screened=False) + check("2. with no clinical screen run the lead says so, never that nothing needs a doctor", not bad, + (bad, lead_text(res3["lead"]))) + old_lead = [["No finding in what these steps assessed needs a doctor; ", True], + ["sections 8 and 9 say what they do not see.", False]] + control("a lead that calls a run with no clinical screen clear of doctor findings", + not lead_problems(dict(res3, lead=old_lead), screened=False)) + planted = dict(res, lead=live_results.lead([f for f in res["findings"] if f["level"] != "act"], res["levels"], + SUMMARY)) + control("the act findings left out of the lead", not lead_problems(planted)) + untagged = dict(res, lead=[x[:2] for x in res["lead"]]) + control("the names of the findings to act on not marked for a child's page", not lead_problems(untagged)) + + # 3. + root = os.path.join(work, "root") + os.makedirs(root) + subprocess.run([sys.executable, os.path.join(REPO, "tests", "demo", "make_demo_sample.py"), "--genome-dir", root, + "--records", "2000"], check=True, stdout=subprocess.DEVNULL) + d = os.path.join(root, SAMPLE) + summary = collect_summary.collect(SAMPLE, d) + with open(os.path.join(d, "summary.json"), "w") as f: + json.dump(summary, f) + found = live_results.build(SAMPLE, summary)["findings"] + check("3. the demo sample has a finding to open", bool(found), found) + pwfile = os.path.join(work, "password") + with open(pwfile, "w") as f: + f.write(owngenome_live.hash_password(PASSWORD) + "\n") + srv = serve(root, os.path.join(work, "state"), pwfile) + srvs.append(srv) + fid = found[0]["id"] if found else "none" + bad = route_problems(srv[1].server_address[1], fid) + check("3. /runs//finding// behind the login, the page for a finding of the run, 404 for any other", + not bad, bad) + real = live_results.FINDING_ID + build = live_results.build + live_results.FINDING_ID = re.compile(r".*") + live_results.build = lambda *a, **k: dict(build(*a, **k), findings=[{"id": x} for x in ( + fid, "not-a-finding-of-this-run", "../../summary.json", "UPPER")]) + try: + control("the route answering any id", not route_problems(srv[1].server_address[1], fid)) + finally: + live_results.FINDING_ID = real + live_results.build = build + # 4. the home page's overview of a running run: the summary its page last read, only while current. + live = srv[0] + run = live.runs()[SAMPLE] + real_state = live.base_state + + def running(r, *a, **k): + st = real_state(r, *a, **k) + return dict(st, run=dict(st["run"], state="running")) + live.base_state = running + try: + st = running(run) + done = tuple(x["step"] for x in st["steps"] if x["state"] == "done") + stamp = live.file_stamp(os.path.join(d, "summary.json")) + extra = copy.deepcopy(summary) + extra.setdefault("sections", {})["cpsr"] = SUMMARY["sections"]["cpsr"] + want = live_results.overview(summary)["levels"].get("act") + + def act_count(key): + live.headline_cache[SAMPLE] = (key, None, extra) + live.live_overview.pop(SAMPLE, None) + item = [x for x in live.runs_index()["runs"] if x["name"] == SAMPLE][0] + return (item.get("levels") or {}).get("act") + got = act_count((SAMPLE, done, None, (1, 1))) + check("4. the home page drops the page's summary once summary.json has changed since that read", + got == want, (got, want)) + control("a page summary read before summary.json changed is still used", + act_count((SAMPLE, done, None, stamp)) == want) + got = act_count((SAMPLE, done + ("99",), None, stamp)) + check("4. ...and once the steps done have changed", got == want, (got, want)) + o = live_results.overview(summary) + bare = {"sections": {k: SUMMARY["sections"][k] for k in ("cpic", "coverage")}} + check("4. runs.json says whether a clinical screen has a result, so the home page claims nothing without one", + live_results.overview(bare)["screened"] is False and o["screened"] is True + and "screened" in [x for x in live.runs_index()["runs"] if x["name"] == SAMPLE][0], + (live_results.overview(bare)["screened"], o["screened"])) + finally: + live.base_state = real_state + live.headline_cache.pop(SAMPLE, None) + finally: + for s in srvs: + stop(s) + 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())