Loading the family.
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}}
-Loading the family.