Repository navigation
feat(live): show each person's results and each family on the live site, not a grid of step boxes #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat(live): show each person's results and each family on the live site, not a grid of step boxes #328
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
dd4544b
fix(contract): make the 1.0 output contract match where each file is …
GeiserX 8e97a49
fix(contract): mark the Parascopy copy-number tables as written by bo…
GeiserX 56aecae
fix(contract): mark 23 more files the step scripts also write as legs…
GeiserX fe15f02
fix(contract): read a saveAs closure to its own brace, match script p…
GeiserX 74a80d2
feat(report): give every section a caveat and key them by section id
GeiserX 04079e6
feat(live): serve each person's results, the runs and each family as …
GeiserX 0e302d7
feat(live): turn each run page into the person's results, with a home…
GeiserX f9a56e7
Merge origin/main into pgp/contract-2
GeiserX 4cfc3c9
test(live): build the new pages into the demo and check results.json,…
GeiserX e12e451
test(live): measure phone width on a desktop viewport and allow the r…
GeiserX b597b7b
docs(live): describe the person, home and family pages and retake the…
GeiserX 51f478f
fix(live): give the step map the full width and keep the Progress tab…
GeiserX 5c9bab4
fix(live): read the APOE rows behind their warning as the report does…
GeiserX 44ece2f
test(live): keep the APOE row's rsID and genotype off one source line…
GeiserX 863dca1
fix(live): show what the report says of each section, and keep the ca…
GeiserX 90b55e3
train: #319 on main and the PRs queued before it
GeiserX 10a08ca
train: #328 on main and the PRs queued before it
GeiserX File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| // core.js: what the live site's pages share (home.html, index.html, family.html). Element builders, | ||
| // a fetch that sends the session and goes to the login on a 401, the pedigree symbol, dates, the | ||
| // caveat block, the facts list and the table. Text only through textContent and createElementNS: | ||
| // no innerHTML, no inline script, no eval. Everything hangs on window.PGPUI. | ||
| (function () { | ||
| 'use strict'; | ||
| var root = document.documentElement; | ||
| var SVGNS = 'http://www.w3.org/2000/svg'; | ||
| var blocked = []; | ||
| document.addEventListener('securitypolicyviolation', function (e) { | ||
| blocked.push(e.violatedDirective + ' ' + (e.blockedURI || '')); | ||
| root.setAttribute('data-pgp-blocked', blocked.join(' | ')); | ||
| }); | ||
|
|
||
| function el(tag, attrs, text) { | ||
| var e = document.createElement(tag); | ||
| Object.keys(attrs || {}).forEach(function (k) { | ||
| if (attrs[k] !== null && attrs[k] !== undefined && attrs[k] !== false) { e.setAttribute(k, attrs[k]); } | ||
| }); | ||
| if (text !== undefined && text !== null) { e.textContent = String(text); } | ||
| return e; | ||
| } | ||
| function svg(tag, attrs) { | ||
| var e = document.createElementNS(SVGNS, tag); | ||
| Object.keys(attrs || {}).forEach(function (k) { e.setAttribute(k, attrs[k]); }); | ||
| return e; | ||
| } | ||
| function add(parent) { | ||
| for (var i = 1; i < arguments.length; i++) { | ||
| var c = arguments[i]; | ||
| if (c === null || c === undefined || c === false) { continue; } | ||
| parent.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); | ||
| } | ||
| return parent; | ||
| } | ||
| function getJSON(url) { | ||
| return fetch(url, { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { | ||
| if (r.status === 401) { window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); return null; } | ||
| return r.ok ? r.json() : null; | ||
| }); | ||
| } | ||
| // The pedigree symbol: a square (male), a circle (female) or a diamond (not known); filled when sequenced. | ||
| function avatar(sex, filled, ratio) { | ||
| var a = el('span', { 'class': 'avatar sex-' + (sex === 'male' || sex === 'female' ? sex : 'unknown') + (filled ? ' filled' : '') , | ||
| 'aria-hidden': 'true' }); | ||
| if (typeof ratio === 'number') { | ||
| a.classList.add('ring'); | ||
| a.style.setProperty('--p', String(Math.max(0, Math.min(1, ratio)))); | ||
| } | ||
| add(a, el('span', { 'class': 'glyph' })); | ||
| return a; | ||
| } | ||
| function day(utc) { return utc ? String(utc).slice(0, 10) : ''; } | ||
| function firstSentence(t) { | ||
| var m = /^(.+?[.!?])(\s+[A-Z(]|$)/.exec(t || ''); | ||
| return m ? m[1] : t; | ||
| } | ||
| // The caveats of a card, word for word: the first sentence of the first one in view, all of them in a | ||
| // details element (in the page, so a reader or a search finds them). | ||
| function caveat(list) { | ||
| if (!list || !list.length) { return null; } | ||
| var box = el('div', { 'class': 'caveat' }); | ||
| var lead = firstSentence(list[0]); | ||
| add(box, el('p', { 'class': 'caveat-lead' }, lead)); | ||
| var d = el('details', { 'class': 'caveat-full' }); | ||
| add(d, el('summary', {}, list.length > 1 || lead !== list[0] ? 'Full caveat' : 'Caveat')); | ||
| list.forEach(function (t) { add(d, el('p', {}, t)); }); | ||
| add(box, d); | ||
| return box; | ||
| } | ||
| function facts(rows) { | ||
| if (!rows || !rows.length) { return null; } | ||
| var dl = el('dl', { 'class': 'facts' }); | ||
| rows.forEach(function (r) { | ||
| var w = el('div', { 'class': 'fact' }); | ||
| add(w, el('dt', {}, r[0]), el('dd', {}, r[1])); | ||
| add(dl, w); | ||
| }); | ||
| return dl; | ||
| } | ||
| // A table; on a phone each row becomes a block of label: value pairs (the label is data-label). | ||
| function table(t, limit) { | ||
| if (!t || !t.rows || !t.rows.length) { return null; } | ||
| limit = limit || 8; | ||
| var wrap = el('div', { 'class': 'table-wrap' }); | ||
| var tb = el('table', { 'class': 'rows' }); | ||
| var head = el('tr', {}); | ||
| t.columns.forEach(function (c) { add(head, el('th', { scope: 'col' }, c)); }); | ||
| add(tb, add(el('thead', {}), head)); | ||
| var body = el('tbody', {}); | ||
| t.rows.forEach(function (r, i) { | ||
| var tr = el('tr', i >= limit ? { 'class': 'extra', hidden: 'hidden' } : {}); | ||
| r.forEach(function (c, j) { add(tr, el('td', { 'data-label': t.columns[j] || '' }, c)); }); | ||
| add(body, tr); | ||
| }); | ||
| add(tb, body); | ||
| add(wrap, tb); | ||
| if (t.rows.length > limit) { | ||
| var b = el('button', { type: 'button', 'class': 'more' }, 'Show all ' + t.rows.length + ' rows'); | ||
| b.addEventListener('click', function () { | ||
| Array.prototype.forEach.call(body.querySelectorAll('tr.extra'), function (x) { x.hidden = false; }); | ||
| b.remove(); | ||
| }); | ||
| add(wrap, b); | ||
| } | ||
| // Rows past results.json's cap are only in the report: said apart from the button, never promised by it. | ||
| if (t.more > 0) { | ||
| add(wrap, el('p', { 'class': 'dim' }, t.more + ' more rows in the report')); | ||
| } | ||
| return wrap; | ||
| } | ||
| // role=tablist keyboard pattern: arrow keys move, Home and End jump; Enter or a click activates. | ||
| function tablist(list, onPick) { | ||
| list.addEventListener('keydown', function (e) { | ||
| var tabs = Array.prototype.slice.call(list.querySelectorAll('[role=tab]')); | ||
| var i = tabs.indexOf(document.activeElement); | ||
| if (i < 0) { return; } | ||
| var j = { ArrowDown: i + 1, ArrowRight: i + 1, ArrowUp: i - 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key]; | ||
| if (j === undefined) { return; } | ||
| e.preventDefault(); | ||
| j = (j + tabs.length) % tabs.length; | ||
| tabs[j].focus(); | ||
| onPick(tabs[j]); | ||
| }); | ||
| } | ||
|
|
||
| window.PGPUI = { el: el, svg: svg, add: add, getJSON: getJSON, avatar: avatar, day: day, caveat: caveat, | ||
| facts: facts, table: table, tablist: tablist, firstSentence: firstSentence }; | ||
| }()); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| // family-page.js: a family's page (assets/live/family.html), from family.json | ||
| // (bin/owngenome_live_family.py): the pedigree with each sequenced person a link to their page, the | ||
| // members with their run progress, what steps 38 and 39 planned, each couple's carrier screen in full | ||
| // (both carry, then X-linked, then one partner), who is related how, and each child's chromosome | ||
| // map. It draws what the steps wrote and computes nothing about genetics. Text through textContent, | ||
| // the drawing through createElementNS; images from this server only. | ||
| (function () { | ||
| 'use strict'; | ||
| var U = window.PGPUI; | ||
| if (!U) { return; } | ||
| var el = U.el, add = U.add, root = document.documentElement; | ||
| var RESULT = { shared: 'Both carry', x_linked: 'X-linked', one_partner: 'One partner' }; | ||
|
|
||
| function section(title, cls) { | ||
| var s = el('section', { 'class': 'card ' + (cls || '') }); | ||
| add(s, el('h2', {}, title)); | ||
| return s; | ||
| } | ||
| function memberUrl(f, s) { return f.runs && f.runs[s] ? f.runs[s].url : null; } | ||
|
|
||
| // The pedigree drawn from the layout the server gives: a sequenced person with a run here is a link. | ||
| function pedigree(f) { | ||
| var L = f.layout, st = f.state; | ||
| var box = el('figure', { 'class': 'pedigree-figure' }); | ||
| if (!L) { add(box, el('p', { 'class': 'dim' }, st.error || 'No pedigree.')); return box; } | ||
| var s = U.svg('svg', { viewBox: '0 0 ' + L.width + ' ' + L.height, role: 'group', 'aria-label': 'Pedigree of ' + st.name, | ||
| 'class': 'pedigree-svg', preserveAspectRatio: 'xMidYMin meet' }); | ||
| var lines = U.svg('g', { 'class': 'ped-lines' }); | ||
| L.lines.forEach(function (l) { lines.appendChild(U.svg('line', { x1: l[0], y1: l[1], x2: l[2], y2: l[3] })); }); | ||
| s.appendChild(lines); | ||
| s.style.maxWidth = Math.round(L.width * 1.1) + 'px'; // a two-person pedigree stays its own size | ||
| var h = L.size / 2; | ||
| L.people.forEach(function (p) { | ||
| var url = memberUrl(f, p.sample); | ||
| var g = U.svg(url ? 'a' : 'g', url ? { href: url, 'class': 'ped-person link', 'aria-label': p.sample + ': open their page' } | ||
| : { 'class': 'ped-person', 'aria-label': p.sample + (p.sequenced ? ': no run on this server' : ': not sequenced') }); | ||
| var cls = 'ped-shape' + (p.sequenced ? ' filled' : ''); | ||
| var shape = p.sex === 'male' ? U.svg('rect', { x: p.x - h, y: p.y - h, width: L.size, height: L.size, 'class': cls }) | ||
| : p.sex === 'female' ? U.svg('circle', { cx: p.x, cy: p.y, r: h, 'class': cls }) | ||
| : U.svg('polygon', { points: [p.x, p.y - h, p.x + h, p.y, p.x, p.y + h, p.x - h, p.y].join(' '), 'class': cls }); | ||
| g.appendChild(shape); | ||
| var t = U.svg('text', { x: p.x, y: p.y + h + 17, 'text-anchor': 'middle', 'class': 'ped-name' }); | ||
| t.textContent = p.sample; | ||
| g.appendChild(t); | ||
| var r = f.runs && f.runs[p.sample]; | ||
| if (r && r.attention) { | ||
| var b = U.svg('text', { x: p.x + h + 4, y: p.y - h + 4, 'class': 'ped-flag' }); | ||
| b.textContent = '▲ ' + r.attention; | ||
| g.appendChild(b); | ||
| } | ||
| var title = U.svg('title', {}); | ||
| title.textContent = p.sample + ', ' + (p.sex || 'sex not known') + (p.sequenced ? ', sequenced' : ', not sequenced'); | ||
| g.appendChild(title); | ||
| s.appendChild(g); | ||
| }); | ||
| add(box, s); | ||
| add(box, el('figcaption', { 'class': 'dim' }, 'Square: male. Circle: female. Diamond: sex not known. Filled: sequenced. ' + | ||
| 'A person with a run on this server opens their page; ▲ counts the cards their report flags.')); | ||
| var d = el('details', { 'class': 'pedigree-image' }); | ||
| add(d, el('summary', {}, 'The drawing as an image')); | ||
| var img = el('img', { src: st.pedigree, alt: 'Pedigree of ' + st.name, 'class': 'pedigree' }); | ||
| add(d, img); | ||
| add(box, d); | ||
| return box; | ||
| } | ||
|
|
||
| function members(f) { | ||
| var s = section('Members', 'members'); | ||
| var ul = el('ul', { 'class': 'people' }); | ||
| f.state.members.forEach(function (m) { | ||
| var r = f.runs && f.runs[m.sample]; | ||
| var li = el('li', { 'class': 'person-card' + (r ? ' has-run' : '') }); | ||
| var inner = r ? el('a', { href: r.url, 'class': 'person-link' }) : el('div', { 'class': 'person-link' }); | ||
| add(inner, U.avatar(m.sex, m.sequenced, r && r.planned ? r.done / r.planned : null)); | ||
| var txt = el('div', { 'class': 'person-text' }); | ||
| add(txt, el('span', { 'class': 'person-name' }, m.sample)); | ||
| var rel = []; | ||
| if (m.father || m.mother) { rel.push('child of ' + [m.father, m.mother].filter(Boolean).join(' and ')); } | ||
| if (m.partner) { rel.push('partner of ' + m.partner); } | ||
| if (rel.length) { add(txt, el('span', { 'class': 'person-place' }, rel.join('; '))); } | ||
| var state = !m.sequenced ? 'Not sequenced' : !r ? 'No run on this server' | ||
| : r.state === 'finished' ? 'Finished' : r.done + ' of ' + r.planned + ' steps'; | ||
| add(txt, el('span', { 'class': 'person-state' }, state)); | ||
| if (r && r.results) { | ||
| add(txt, el('span', { 'class': 'person-look' + (r.attention ? ' attn' : '') }, | ||
| r.attention ? r.attention + ' worth a look' : 'Nothing flagged; see the caveats')); | ||
| } | ||
| add(inner, txt); | ||
| add(li, inner); | ||
| add(ul, li); | ||
| }); | ||
| add(s, ul); | ||
| return s; | ||
| } | ||
|
|
||
| function analyses(f) { | ||
| var plan = f.state.plan || []; | ||
| var p = el('p', { 'class': 'analyses' }); | ||
| if (!plan.length) { add(p, 'Analyses: none planned yet (step 38 writes family_plan.tsv).'); return p; } | ||
| add(p, el('span', { 'class': 'label' }, 'Analyses: ')); | ||
| plan.forEach(function (r, i) { | ||
| if (i) { add(p, ', '); } | ||
| var word = r.decision === 'runs' ? r.analysis.replace(/_/g, ' ') : r.analysis.replace(/_/g, ' ') + ' (' + r.decision + ')'; | ||
| add(p, el('span', { 'class': 'plan-item d-' + r.decision, title: r.reason || '' }, word)); | ||
| }); | ||
| return p; | ||
| } | ||
|
|
||
| function couples(f) { | ||
| var s = section('Couple carrier screen', 'couples'); | ||
| add(s, el('p', { 'class': 'caveat-lead' }, f.couple_note)); | ||
| if (!f.state.couples.length) { add(s, el('p', { 'class': 'dim' }, 'The pedigree names no partners.')); } | ||
| f.state.couples.forEach(function (c) { | ||
| var p = el('p', { 'data-couple': c.a + ' ' + c.b, 'class': 'couple-line' }); | ||
| if (!c.screened) { | ||
| p.textContent = c.a + ' and ' + c.b + ': not screened yet (step 39).'; | ||
| add(s, p); | ||
| return; | ||
| } | ||
| var rows = (f.couples || {})[c.a + ' ' + c.b] || []; | ||
| var one = rows.filter(function (r) { return r.result === 'one_partner'; }).length; | ||
| // A count of zero is not a clean result: the screen sees only what it lists as assessed. | ||
| p.textContent = c.a + ' and ' + c.b + ': ' + c.shared + ' gene(s) where both carry a finding, ' + c.x_linked + | ||
| ' X-linked, ' + one + ' where one partner carries a finding, unless the other partner carries something the ' + | ||
| 'screen does not see. '; | ||
| if (c.report) { add(p, el('a', { href: c.report }, 'What the screen does not see')); } | ||
| add(s, p); | ||
| add(s, U.table({ columns: ['Result', 'Gene', 'Condition', 'Chance for a child', c.a, c.b, 'Why'], | ||
| rows: rows.map(function (r) { | ||
| var aFirst = r.partner_a === c.a; | ||
| return [RESULT[r.result] || r.result, r.gene, r.condition, r.offspring_risk, | ||
| aFirst ? r.findings_a : r.findings_b, aFirst ? r.findings_b : r.findings_a, r.explanation]; | ||
| }) }, 12)); | ||
| }); | ||
| return s; | ||
| } | ||
|
|
||
| function related(f) { | ||
| var s = section('Who is related how', 'related'); | ||
| var rel = f.relationships || [], ibd = f.ibd_pairs || []; | ||
| if (!rel.length && !ibd.length) { add(s, el('p', { 'class': 'dim' }, 'Not run yet (step 38).')); return s; } | ||
| var byPair = {}; | ||
| ibd.forEach(function (r) { byPair[[r.sample_a, r.sample_b].sort().join(' ')] = r; }); | ||
| var rows = rel.map(function (r) { | ||
| var i = byPair[[r.sample_a, r.sample_b].sort().join(' ')] || {}; | ||
| return [r.sample_a + ' and ' + r.sample_b, r.declared, r.relatedness, r.ibs0, r.ibs2, i.shared_cm || '.', | ||
| i.degree || '.', r.verdict, r.reason]; | ||
| }); | ||
| ibd.forEach(function (i) { | ||
| if (!rel.some(function (r) { return [r.sample_a, r.sample_b].sort().join(' ') === [i.sample_a, i.sample_b].sort().join(' '); })) { | ||
| rows.push([i.sample_a + ' and ' + i.sample_b, i.declared, i.relatedness, '.', '.', i.shared_cm, i.degree, '.', '.']); | ||
| } | ||
| }); | ||
| add(s, U.table({ columns: ['Pair', 'Declared', 'Relatedness', 'IBS0', 'IBS2', 'Shared cM', 'Degree', 'Verdict', 'Reason'], | ||
| rows: rows }, 20)); | ||
| if (!ibd.length) { add(s, el('p', { 'class': 'dim' }, 'Shared segments need step 26b’s panel: only the somalier half is here.')); } | ||
| return s; | ||
| } | ||
|
|
||
| function children(f) { | ||
| var s = section('Inherited stretches', 'children'); | ||
| if (!f.state.children.length) { add(s, el('p', { 'class': 'dim' }, 'The pedigree names no child.')); } | ||
| f.state.children.forEach(function (c) { | ||
| var d = el('details', { 'data-child': c.child }); | ||
| add(d, el('summary', {}, c.child + ': father ' + (c.father || 'not named') + ', mother ' + (c.mother || 'not named'))); | ||
| if (c.segments) { | ||
| add(d, el('img', { src: c.segments, alt: 'Chromosome map of ' + c.child, 'class': 'segments' })); | ||
| } else { | ||
| add(d, el('p', { 'class': 'dim' }, 'No chromosome map yet: step 38 draws one for a child with a sequenced parent.')); | ||
| } | ||
| add(s, d); | ||
| }); | ||
| return s; | ||
| } | ||
|
|
||
| function more(f) { | ||
| var s = section('Plan and reports', 'plan'); | ||
| var plan = f.state.plan || []; | ||
| if (plan.length) { | ||
| add(s, U.table({ columns: ['Analysis', 'Members', 'Decision', 'Reason'], | ||
| rows: plan.map(function (r) { return [r.analysis, r.members, r.decision, r.reason]; }) }, 20)); | ||
| } | ||
| if (f.state.reports.length) { | ||
| var ul = el('ul', { 'class': 'lines' }); | ||
| f.state.reports.forEach(function (r) { add(ul, add(el('li', {}), el('a', { href: r.url }, r.name))); }); | ||
| add(s, ul); | ||
| } | ||
| return s; | ||
| } | ||
|
|
||
| function draw(f) { | ||
| var main = document.getElementById('family'); | ||
| main.textContent = ''; | ||
| var page = el('section', { 'class': 'family-page', 'data-plugin': 'family', 'aria-label': 'Family ' + f.state.name }); | ||
| if (f.state.error) { | ||
| add(page, el('p', {}, f.state.error)); | ||
| add(main, page); | ||
| root.setAttribute('data-pgp-status', 'ready'); | ||
| return; | ||
| } | ||
| var top = el('div', { 'class': 'family-top' }); | ||
| var ped = section('Pedigree', 'ped'); | ||
| add(ped, pedigree(f), analyses(f)); | ||
| add(top, ped, members(f)); | ||
| add(page, top); | ||
| var grid = el('div', { 'class': 'family-grid' }); | ||
| add(grid, couples(f), related(f)); | ||
| add(page, grid, children(f)); | ||
| if ((f.state.plan || []).length || f.state.reports.length) { add(page, more(f)); } | ||
| add(main, page); | ||
| root.setAttribute('data-pgp-status', 'ready'); | ||
| } | ||
|
|
||
| // The page asks again every 30 s and redraws only when family.json changed (a member's run moved on, | ||
| // step 39 finished). A failed first request says so, with a retry; a later one keeps what is shown. | ||
| var shown = null; | ||
| function failed() { | ||
| if (shown !== null) { return; } | ||
| var main = document.getElementById('family'); | ||
| main.textContent = ''; | ||
| var b = el('button', { type: 'button', 'class': 'more' }, 'Try again'); | ||
| b.addEventListener('click', function () { main.textContent = ''; add(main, el('p', { 'class': 'dim' }, 'Loading the family.')); fetchFamily(); }); | ||
| add(main, el('p', {}, 'The family could not be loaded.'), b); | ||
| root.setAttribute('data-pgp-status', 'offline'); | ||
| } | ||
| function fetchFamily() { | ||
| return U.getJSON('family.json').then(function (f) { | ||
| if (!f) { failed(); return; } | ||
| var k = JSON.stringify(f); | ||
| if (k !== shown) { shown = k; draw(f); } | ||
| }).catch(failed); | ||
| } | ||
| function start() { | ||
| fetchFamily(); | ||
| window.setInterval(function () { if (!document.hidden) { fetchFamily(); } }, 30000); | ||
| } | ||
| if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } | ||
| }()); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| <!doctype html> | ||
| <html lang="en" data-theme="dark" data-mode="{{mode}}" data-run="{{run}}"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>Family {{run}}: OwnGenome</title> | ||
| <link rel="stylesheet" href="{{assets}}/live.css"> | ||
| <script src="{{assets}}/core.js" defer></script> | ||
| <script src="{{assets}}/family-page.js" defer></script> | ||
| {{plugins}} | ||
| </head> | ||
| <body class="page-family"> | ||
| <header class="top"> | ||
| <a class="brand" href="/">OwnGenome</a> | ||
| <nav class="crumbs" aria-label="Where you are"><span id="run-name">Family {{run}}</span></nav> | ||
| <nav class="links"><a href="/" id="home-link">All runs</a></nav> | ||
| </header> | ||
| <main id="family" class="family"> | ||
| <p class="dim">Loading the family.</p> | ||
| </main> | ||
| </body> | ||
| </html> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.