feat: redesign around phenotype-driven triage, not variant filtering
A table with filters made the user do the work. Rare disease triage is a different task:
which few variants could explain *this* patient's phenotype, and why. The app now answers
that, and lets a reviewer act on the answer.
Domain
- a case is a proband: a VCF plus the HPO terms observed in the patient (samples -> cases)
- HPO's gene-to-phenotype annotations are loaded as reference data (scripts/load-hpo.py)
- each candidate can be shortlisted or dismissed with a reason and a note
Ranking (app/services/triage.py, 21 tests)
- weighted sum of phenotype match, rarity, consequence severity and the model's score,
with every component shown next to the candidate
- rarity and consequence filter; phenotype only ranks, because a real diagnosis can sit in
a gene nobody has annotated yet and filtering on it would hide exactly that case
- ClinVar is deliberately not an input: it appears beside the result as independent
confirmation, so nothing ranks highly merely because ClinVar already said pathogenic
UI
- the funnel is the headline: variants called -> rare -> coding candidates -> phenotype-matched
- ranked candidates with evidence chips, not a grid of everything; filters are demoted
- a variant panel showing the score breakdown, the matched HPO terms, the raw VEP record and
links out to Ensembl/gnomAD/ClinVar, with the decision controls
- a printable case report: phenotype, funnel, shortlisted variants with reasons, provenance
API: /cases with phenotypes, /cases/{id}/candidates (funnel + ranked + weights),
/variants/{id}, /variants/{id}/decision, /cases/{id}/report, /phenotypes for the picker.
Scoring moved under the case and now answers 503 with the reason when no model registry is
reachable, instead of a 500.
Verified end to end on a simulated proband (scripts/make-demo-case.sh: real GIAB HG002
background + one real ClinVar 2-star pathogenic NF2 variant). 13 variants called -> 1 coding
candidate, and the planted variant ranks first at 0.80 on phenotype 1.00, rarity 1.00 and
consequence 1.00, with ClinVar agreeing afterwards.
Tests: api 75, ml 18, loader 16, web 27; ruff, mypy, svelte-check, terraform validate, both
kustomize overlays and the Nextflow stub run all clean.
This commit is contained in:
+87
-1
@@ -12,7 +12,7 @@
|
||||
}
|
||||
html { background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 17px; }
|
||||
body { margin: 0; }
|
||||
main { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.5rem; }
|
||||
main { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; }
|
||||
h1 { font-weight: 600; font-size: 2rem; letter-spacing: -0.01em; margin: 0 0 0.5rem; }
|
||||
h2 { font-weight: 600; font-size: 1.25rem; margin: 2rem 0 0.75rem; }
|
||||
p.lede { color: var(--ink-soft); max-width: 60ch; margin: 0 0 2rem; }
|
||||
@@ -40,3 +40,89 @@ td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned
|
||||
.spinner { animation: spin 0.9s linear infinite; }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* --- triage ------------------------------------------------------------------------------- */
|
||||
.muted { color: var(--ink-soft); }
|
||||
.actions { display: flex; align-items: center; gap: 1rem; }
|
||||
.reportlink { font-weight: 600; }
|
||||
.provenance { font-size: 0.85rem; margin: 0.35rem 0 0; }
|
||||
.casehead { margin-bottom: 1.25rem; }
|
||||
.casehead h1 { margin-bottom: 0.4rem; }
|
||||
.log { white-space: pre-wrap; background: white; border: 1px solid var(--line); padding: 0.75rem;
|
||||
max-height: 14rem; overflow: auto; font-size: 0.8rem; }
|
||||
|
||||
.chips { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||
.chip { font-size: 0.78rem; padding: 0.12rem 0.5rem; border-radius: 999px; border: 1px solid var(--line);
|
||||
background: white; color: var(--ink-soft); white-space: nowrap; }
|
||||
.chip.match { background: var(--plum-soft); border-color: transparent; color: var(--plum); font-weight: 600; }
|
||||
.chip.rare { border-color: var(--ink-soft); color: var(--ink); }
|
||||
.chip.impact { background: var(--ink); border-color: var(--ink); color: white; }
|
||||
.chip.model { border-style: dashed; color: var(--ink); }
|
||||
.chip.clinvar { background: #fdf1df; border-color: transparent; color: var(--amber); font-weight: 600; }
|
||||
.chipx { background: none; border: none; color: inherit; padding: 0 0 0 0.3rem; cursor: pointer; font: inherit; }
|
||||
|
||||
/* The funnel is the headline: thousands of variants down to a handful. */
|
||||
.funnel { list-style: none; padding: 0; margin: 0 0 1.5rem; display: grid; gap: 0.35rem; }
|
||||
.funnel li { display: grid; grid-template-columns: 5rem 16rem 1fr; align-items: center; gap: 0.75rem; }
|
||||
.funnel-value { font-family: var(--mono); font-size: 1.05rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.funnel-label { color: var(--ink-soft); font-size: 0.9rem; }
|
||||
.funnel-track { background: white; border: 1px solid var(--line); height: 0.75rem; border-radius: 2px; }
|
||||
.funnel-bar { display: block; height: 100%; background: var(--plum); opacity: 0.75; }
|
||||
|
||||
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
|
||||
.triage { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.25rem; align-items: start; }
|
||||
@media (min-width: 62rem) { .triage { grid-template-columns: minmax(0, 1fr) 24rem; } }
|
||||
|
||||
.candidate { display: grid; grid-template-columns: 2rem minmax(10rem, 16rem) 1fr auto auto; gap: 0.75rem;
|
||||
align-items: center; width: 100%; text-align: left; background: white; color: var(--ink);
|
||||
border: 1px solid var(--line); border-left: 3px solid transparent; border-radius: 4px;
|
||||
padding: 0.6rem 0.75rem; margin-bottom: 0.4rem; font: inherit; cursor: pointer; }
|
||||
.candidate:hover { border-left-color: var(--plum-soft); }
|
||||
.candidate.selected { border-left-color: var(--plum); background: #fbf7fa; }
|
||||
.candidate .rank { font-family: var(--mono); color: var(--ink-soft); }
|
||||
.candidate .who { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.candidate .who .coord, .candidate .who .hgvs { font-size: 0.78rem; color: var(--ink-soft); }
|
||||
.rankscore { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.scorebar { width: 4rem; height: 0.5rem; background: var(--line); border-radius: 2px; overflow: hidden; }
|
||||
.scorebar span { display: block; height: 100%; background: var(--plum); }
|
||||
.scorenum { font-family: var(--mono); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
||||
.decision { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; }
|
||||
.decision.shortlisted { color: var(--plum); }
|
||||
.decision.dismissed { color: var(--ink-soft); }
|
||||
|
||||
.panel { background: white; border: 1px solid var(--line); border-radius: 4px; padding: 1rem; position: sticky; top: 1rem; }
|
||||
.panel-head { display: flex; justify-content: space-between; align-items: start; gap: 0.5rem; }
|
||||
.panel h3 { margin: 0 0 0.5rem; font-size: 1.05rem; }
|
||||
.panel h4 { margin: 1.1rem 0 0.4rem; font-size: 0.78rem; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--ink-soft); }
|
||||
.panel input, .panel textarea { width: 100%; margin-bottom: 0.5rem; font-family: inherit; }
|
||||
.components { border: none; background: none; }
|
||||
.components th { font-weight: 400; color: var(--ink-soft); padding: 0.15rem 0.5rem 0.15rem 0; }
|
||||
.components td { padding: 0.15rem 0; font-size: 0.82rem; text-align: right; }
|
||||
.evidence { display: grid; grid-template-columns: 7rem 1fr; gap: 0.2rem 0.5rem; margin: 0; font-size: 0.88rem; }
|
||||
.evidence dt { color: var(--ink-soft); }
|
||||
.evidence dd { margin: 0; }
|
||||
.links { display: flex; gap: 0.75rem; font-size: 0.85rem; }
|
||||
.decide { display: flex; gap: 0.5rem; }
|
||||
|
||||
.newcase { display: grid; gap: 0.6rem; margin-bottom: 1.5rem; }
|
||||
.newcase .row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.newcase button { justify-self: start; }
|
||||
.picker { position: relative; display: grid; gap: 0.4rem; }
|
||||
.picker .results { list-style: none; margin: 0; padding: 0.25rem; background: white;
|
||||
border: 1px solid var(--line); border-radius: 4px; max-height: 12rem; overflow: auto; }
|
||||
.picker .results button { width: 100%; text-align: left; border: none; padding: 0.3rem 0.4rem; }
|
||||
.picker .results button:hover { background: var(--plum-soft); }
|
||||
|
||||
.caselist { list-style: none; padding: 0; margin: 0; }
|
||||
.caselist li { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 0.7rem 0.75rem;
|
||||
background: white; border: 1px solid var(--line); border-radius: 4px; margin-bottom: 0.4rem; }
|
||||
.casename { font-weight: 600; min-width: 10rem; }
|
||||
|
||||
.report { background: white; border: 1px solid var(--line); padding: 2rem; }
|
||||
.reportitem { border-top: 1px solid var(--line); padding-top: 0.75rem; margin-top: 0.75rem; }
|
||||
.reportitem h3 { margin: 0 0 0.4rem; }
|
||||
.dismissed { color: var(--ink-soft); }
|
||||
.disclaimer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--line);
|
||||
color: var(--ink-soft); font-size: 0.85rem; }
|
||||
@media print { .noprint, nav { display: none; } .report { border: none; padding: 0; } }
|
||||
|
||||
+76
-13
@@ -1,22 +1,71 @@
|
||||
// Dynamic, not static: the same image serves /api behind the ingress and a full URL elsewhere.
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { variantQuery, type VariantFilters } from './query';
|
||||
import { candidateQuery, type CandidateFilters } from './candidates';
|
||||
|
||||
export type Assembly = 'GRCh38' | 'GRCh37';
|
||||
export type Sample = { id: string; name: string; vcf_uri: string; assembly: Assembly; created_at: string };
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
export type PhenotypeTerm = { hpo_id: string; label: string };
|
||||
|
||||
export type Job = {
|
||||
id: string; sample_id: string; status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
log: string | null; created_at: string; finished_at: string | null;
|
||||
id: string; case_id: string; status: JobStatus;
|
||||
vep_version: string | null; log: string | null; created_at: string; finished_at: string | null;
|
||||
};
|
||||
|
||||
export type Case = {
|
||||
id: string; name: string; vcf_uri: string; assembly: Assembly; created_at: string;
|
||||
phenotypes: PhenotypeTerm[]; latest_job: Job | null; shortlisted: number;
|
||||
};
|
||||
|
||||
export type Variant = {
|
||||
id: number; chrom: string; pos: number; ref: string; alt: string; gene: string | null;
|
||||
consequence: string | null; impact: string | null; hgvsc: string | null; hgvsp: string | null;
|
||||
gnomad_af: number | null; clinvar_sig: string | null; prediction: { score: number; model_version: string } | null;
|
||||
gnomad_af: number | null; clinvar_sig: string | null;
|
||||
prediction: { score: number; model_name: string; model_version: string } | null;
|
||||
};
|
||||
|
||||
export type DecisionState = 'shortlisted' | 'dismissed';
|
||||
export type Decision = {
|
||||
state: DecisionState; reason: string | null; note: string | null; decided_at: string;
|
||||
};
|
||||
|
||||
export type Candidate = {
|
||||
variant: Variant;
|
||||
score: number;
|
||||
components: Record<string, number>;
|
||||
matched_terms: PhenotypeTerm[];
|
||||
scored: boolean;
|
||||
decision: Decision | null;
|
||||
};
|
||||
|
||||
export type VariantDetail = Candidate & { annotations: Record<string, string> };
|
||||
export type Funnel = { total: number; rare: number; candidates: number; phenotype_matched: number };
|
||||
|
||||
export type CandidatePage = {
|
||||
funnel: Funnel;
|
||||
weights: Record<string, number>;
|
||||
items: Candidate[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type Report = {
|
||||
case: Case;
|
||||
funnel: Funnel;
|
||||
generated_at: string;
|
||||
provenance: {
|
||||
job_id: string | null; vep_version: string | null; finished_at: string | null;
|
||||
model_name: string | null; model_version: string | null;
|
||||
};
|
||||
shortlisted: Candidate[];
|
||||
dismissed: Candidate[];
|
||||
};
|
||||
export type VariantPage = { items: Variant[]; total: number; limit: number; offset: number };
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(`${env.PUBLIC_API_URL ?? '/api'}${path}`, { headers: { 'content-type': 'application/json' }, ...init });
|
||||
const r = await fetch(`${env.PUBLIC_API_URL ?? '/api'}${path}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
});
|
||||
if (!r.ok) throw new Error(await errorReason(r));
|
||||
return r.json() as Promise<T>;
|
||||
}
|
||||
@@ -34,12 +83,26 @@ async function errorReason(r: Response): Promise<string> {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const post = (body?: unknown): RequestInit => ({
|
||||
method: 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
|
||||
export const api = {
|
||||
samples: () => req<Sample[]>('/samples'),
|
||||
createSample: (body: Pick<Sample, 'name' | 'vcf_uri' | 'assembly'>) =>
|
||||
req<Sample>('/samples', { method: 'POST', body: JSON.stringify(body) }),
|
||||
jobsForSample: (sampleId: string) => req<Job[]>(`/samples/${sampleId}/jobs`),
|
||||
annotate: (sampleId: string) => req<Job>(`/samples/${sampleId}/annotate`, { method: 'POST' }),
|
||||
cases: () => req<Case[]>('/cases'),
|
||||
createCase: (body: { name: string; vcf_uri: string; assembly: Assembly; phenotypes: PhenotypeTerm[] }) =>
|
||||
req<Case>('/cases', post(body)),
|
||||
case: (id: string) => req<Case>(`/cases/${id}`),
|
||||
annotate: (id: string) => req<Job>(`/cases/${id}/annotate`, post()),
|
||||
score: (id: string) => req<{ scored: number; model_version: string }>(`/cases/${id}/score`, post()),
|
||||
job: (id: string) => req<Job>(`/jobs/${id}`),
|
||||
variants: (filters: VariantFilters) => req<VariantPage>(`/variants?${variantQuery(filters)}`)
|
||||
candidates: (caseId: string, filters: CandidateFilters = {}) => {
|
||||
const query = candidateQuery(filters);
|
||||
return req<CandidatePage>(`/cases/${caseId}/candidates${query ? `?${query}` : ''}`);
|
||||
},
|
||||
variant: (id: number) => req<VariantDetail>(`/variants/${id}`),
|
||||
decide: (id: number, body: { state: DecisionState; reason?: string; note?: string }) =>
|
||||
req<Decision>(`/variants/${id}/decision`, post(body)),
|
||||
report: (caseId: string) => req<Report>(`/cases/${caseId}/report`),
|
||||
phenotypes: (q: string) => req<PhenotypeTerm[]>(`/phenotypes?q=${encodeURIComponent(q)}`)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { candidateQuery, evidenceChips, funnelSteps, scoreBarPercent } from './candidates';
|
||||
import type { Candidate, Funnel } from './api';
|
||||
|
||||
const candidate = (over: Partial<Candidate> = {}): Candidate => ({
|
||||
variant: {
|
||||
id: 1, chrom: '22', pos: 100, ref: 'A', alt: 'G', gene: 'NF2',
|
||||
consequence: 'frameshift_variant', impact: 'HIGH', hgvsc: null, hgvsp: 'p.Ser486fs',
|
||||
gnomad_af: null, clinvar_sig: null, prediction: null
|
||||
},
|
||||
score: 0.88,
|
||||
components: { phenotype: 0.67, rarity: 1, consequence: 1, model: 0.94 },
|
||||
matched_terms: [{ hpo_id: 'HP:0000365', label: 'Hearing impairment' }],
|
||||
scored: true,
|
||||
decision: null,
|
||||
...over
|
||||
});
|
||||
|
||||
describe('evidenceChips', () => {
|
||||
it('leads with the phenotype match, because that is what makes it a candidate', () => {
|
||||
const [first] = evidenceChips(candidate(), 3);
|
||||
expect(first).toEqual({ label: '1/3 phenotype terms', tone: 'match' });
|
||||
});
|
||||
|
||||
it('says plainly when the gene has no link to the phenotype', () => {
|
||||
const chips = evidenceChips(candidate({ matched_terms: [] }), 3);
|
||||
expect(chips[0]).toEqual({ label: 'no phenotype match', tone: 'muted' });
|
||||
});
|
||||
|
||||
it('calls an absent variant absent rather than showing a zero', () => {
|
||||
expect(evidenceChips(candidate(), 3)[1]).toEqual({ label: 'absent from gnomAD', tone: 'rare' });
|
||||
});
|
||||
|
||||
it('shows a frequency when there is one', () => {
|
||||
const c = candidate();
|
||||
c.variant.gnomad_af = 0.00042;
|
||||
expect(evidenceChips(c, 3)[1].label).toBe('gnomAD 4.2e-4');
|
||||
});
|
||||
|
||||
it('reads the consequence without the underscores', () => {
|
||||
expect(evidenceChips(candidate(), 3)[2]).toEqual({ label: 'frameshift variant', tone: 'impact' });
|
||||
});
|
||||
|
||||
it('marks an unscored variant instead of implying a zero score', () => {
|
||||
const chips = evidenceChips(candidate({ scored: false }), 3);
|
||||
expect(chips[3]).toEqual({ label: 'unscored', tone: 'muted' });
|
||||
});
|
||||
|
||||
it('shows ClinVar last, as confirmation rather than an input', () => {
|
||||
const c = candidate();
|
||||
c.variant.clinvar_sig = 'pathogenic';
|
||||
expect(evidenceChips(c, 3).at(-1)).toEqual({ label: 'ClinVar: pathogenic', tone: 'clinvar' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('funnelSteps', () => {
|
||||
it('describes each narrowing step in order', () => {
|
||||
const funnel: Funnel = { total: 1284, rare: 41, candidates: 12, phenotype_matched: 6 };
|
||||
expect(funnelSteps(funnel).map((s) => `${s.label} ${s.value}`)).toEqual([
|
||||
'variants called 1284',
|
||||
'rare (<0.1%) 41',
|
||||
'coding candidates 12',
|
||||
'in phenotype-matched genes 6'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoreBarPercent', () => {
|
||||
it('maps the 0..1 score onto a bar width', () => {
|
||||
expect(scoreBarPercent(0.884)).toBe(88);
|
||||
expect(scoreBarPercent(0)).toBe(0);
|
||||
expect(scoreBarPercent(1)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('candidateQuery', () => {
|
||||
it('sends only the filters that are set', () => {
|
||||
expect(candidateQuery({})).toBe('');
|
||||
expect(candidateQuery({ gene: ' nf2 ', impact: 'HIGH', state: 'undecided' })).toBe(
|
||||
'gene=nf2&impact=HIGH&state=undecided'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a cleared allele frequency box', () => {
|
||||
expect(candidateQuery({ maxAf: null })).toBe('');
|
||||
expect(candidateQuery({ maxAf: 0.01 })).toBe('max_af=0.01');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Candidate, Funnel } from './api';
|
||||
|
||||
export type Chip = {
|
||||
label: string;
|
||||
tone: 'match' | 'rare' | 'impact' | 'model' | 'clinvar' | 'muted';
|
||||
};
|
||||
|
||||
export type CandidateFilters = {
|
||||
gene?: string;
|
||||
impact?: string;
|
||||
maxAf?: number | string | null;
|
||||
state?: 'shortlisted' | 'dismissed' | 'undecided' | '';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
const formatAf = (af: number) => af.toExponential(1);
|
||||
|
||||
/**
|
||||
* The reasons this variant is a candidate, in the order a reviewer reads them. ClinVar comes last
|
||||
* and is styled apart, because it confirms the ranking rather than feeding it.
|
||||
*/
|
||||
export function evidenceChips(candidate: Candidate, caseTermCount: number): Chip[] {
|
||||
const { variant } = candidate;
|
||||
const chips: Chip[] = [
|
||||
candidate.matched_terms.length
|
||||
? { label: `${candidate.matched_terms.length}/${caseTermCount} phenotype terms`, tone: 'match' }
|
||||
: { label: 'no phenotype match', tone: 'muted' },
|
||||
variant.gnomad_af === null
|
||||
? { label: 'absent from gnomAD', tone: 'rare' }
|
||||
: { label: `gnomAD ${formatAf(variant.gnomad_af)}`, tone: 'rare' },
|
||||
{
|
||||
label: (variant.consequence ?? 'unknown consequence').split('&')[0].replace(/_/g, ' '),
|
||||
tone: 'impact'
|
||||
},
|
||||
candidate.scored && variant.prediction
|
||||
? { label: `model ${variant.prediction.score.toFixed(2)}`, tone: 'model' }
|
||||
: { label: 'unscored', tone: 'muted' }
|
||||
];
|
||||
if (variant.clinvar_sig) chips.push({ label: `ClinVar: ${variant.clinvar_sig}`, tone: 'clinvar' });
|
||||
return chips;
|
||||
}
|
||||
|
||||
/** Thousands of variants down to a handful: the narrowing is the story. */
|
||||
export function funnelSteps(funnel: Funnel): { label: string; value: number }[] {
|
||||
return [
|
||||
{ label: 'variants called', value: funnel.total },
|
||||
{ label: 'rare (<0.1%)', value: funnel.rare },
|
||||
{ label: 'coding candidates', value: funnel.candidates },
|
||||
{ label: 'in phenotype-matched genes', value: funnel.phenotype_matched }
|
||||
];
|
||||
}
|
||||
|
||||
export const scoreBarPercent = (score: number): number =>
|
||||
Math.max(0, Math.min(100, Math.round(score * 100)));
|
||||
|
||||
export function candidateQuery(filters: CandidateFilters): string {
|
||||
const q = new URLSearchParams();
|
||||
const gene = filters.gene?.trim();
|
||||
if (gene) q.set('gene', gene);
|
||||
if (filters.impact) q.set('impact', filters.impact);
|
||||
const { maxAf } = filters;
|
||||
if (maxAf !== null && maxAf !== undefined && maxAf !== '' && Number.isFinite(Number(maxAf))) {
|
||||
q.set('max_af', String(maxAf));
|
||||
}
|
||||
if (filters.state) q.set('state', filters.state);
|
||||
if (filters.limit !== undefined) q.set('limit', String(filters.limit));
|
||||
if (filters.offset !== undefined) q.set('offset', String(filters.offset));
|
||||
return q.toString();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { Candidate } from '$lib/api';
|
||||
import { evidenceChips, scoreBarPercent } from '$lib/candidates';
|
||||
import Chips from './Chips.svelte';
|
||||
|
||||
let {
|
||||
candidate,
|
||||
rank,
|
||||
termCount,
|
||||
selected = false,
|
||||
onselect
|
||||
}: {
|
||||
candidate: Candidate;
|
||||
rank: number;
|
||||
termCount: number;
|
||||
selected?: boolean;
|
||||
onselect: () => void;
|
||||
} = $props();
|
||||
|
||||
const v = $derived(candidate.variant);
|
||||
</script>
|
||||
|
||||
<button class="candidate" class:selected onclick={onselect}>
|
||||
<span class="rank">{rank}</span>
|
||||
<span class="who">
|
||||
<strong>{v.gene ?? 'intergenic'}</strong>
|
||||
<span class="coord">{v.chrom}:{v.pos} {v.ref}>{v.alt}</span>
|
||||
{#if v.hgvsp ?? v.hgvsc}<span class="hgvs">{v.hgvsp ?? v.hgvsc}</span>{/if}
|
||||
</span>
|
||||
<Chips chips={evidenceChips(candidate, termCount)} />
|
||||
<span class="rankscore">
|
||||
<span class="scorebar"><span style="width: {scoreBarPercent(candidate.score)}%"></span></span>
|
||||
<span class="scorenum">{candidate.score.toFixed(2)}</span>
|
||||
</span>
|
||||
{#if candidate.decision}
|
||||
<span class="decision {candidate.decision.state}">{candidate.decision.state}</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Chip } from '$lib/candidates';
|
||||
let { chips }: { chips: Chip[] } = $props();
|
||||
</script>
|
||||
|
||||
<span class="chips">
|
||||
{#each chips as chip (chip.label)}
|
||||
<span class="chip {chip.tone}">{chip.label}</span>
|
||||
{/each}
|
||||
</span>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { Funnel } from '$lib/api';
|
||||
import { funnelSteps } from '$lib/candidates';
|
||||
|
||||
let { funnel }: { funnel: Funnel } = $props();
|
||||
const steps = $derived(funnelSteps(funnel));
|
||||
const width = (value: number) => (funnel.total ? Math.max(1.5, (value / funnel.total) * 100) : 0);
|
||||
</script>
|
||||
|
||||
<ol class="funnel">
|
||||
{#each steps as step (step.label)}
|
||||
<li>
|
||||
<span class="funnel-value">{step.value.toLocaleString('en-GB')}</span>
|
||||
<span class="funnel-label">{step.label}</span>
|
||||
<span class="funnel-track"><span class="funnel-bar" style="width: {width(step.value)}%"></span></span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { api, type PhenotypeTerm } from '$lib/api';
|
||||
|
||||
let { selected = $bindable() }: { selected: PhenotypeTerm[] } = $props();
|
||||
let query = $state('');
|
||||
let results = $state<PhenotypeTerm[]>([]);
|
||||
let searching = $state(false);
|
||||
|
||||
async function search() {
|
||||
if (query.trim().length < 2) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
try {
|
||||
results = await api.phenotypes(query.trim());
|
||||
} catch {
|
||||
results = []; // the picker is a convenience; a failed lookup must not block the form
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(term: PhenotypeTerm) {
|
||||
if (!selected.some((t) => t.hpo_id === term.hpo_id)) selected = [...selected, term];
|
||||
query = '';
|
||||
results = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<input
|
||||
placeholder="Search HPO terms, e.g. hearing"
|
||||
bind:value={query}
|
||||
oninput={search}
|
||||
aria-label="Phenotype search"
|
||||
/>
|
||||
{#if searching}<span class="muted">searching…</span>{/if}
|
||||
{#if results.length}
|
||||
<ul class="results">
|
||||
{#each results as term (term.hpo_id)}
|
||||
<li>
|
||||
<button type="button" class="quiet" onclick={() => add(term)}>
|
||||
{term.label} <small class="muted">{term.hpo_id}</small>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<span class="chips">
|
||||
{#each selected as term (term.hpo_id)}
|
||||
<span class="chip match">
|
||||
{term.label}
|
||||
<button
|
||||
type="button"
|
||||
class="chipx"
|
||||
onclick={() => (selected = selected.filter((t) => t.hpo_id !== term.hpo_id))}
|
||||
aria-label="Remove {term.label}">✕</button>
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { api, type DecisionState, type VariantDetail } from '$lib/api';
|
||||
import { evidenceChips } from '$lib/candidates';
|
||||
import Chips from './Chips.svelte';
|
||||
|
||||
let {
|
||||
detail,
|
||||
termCount,
|
||||
weights,
|
||||
onclose,
|
||||
ondecided
|
||||
}: {
|
||||
detail: VariantDetail;
|
||||
termCount: number;
|
||||
weights: Record<string, number>;
|
||||
onclose: () => void;
|
||||
ondecided: (updated: VariantDetail) => void;
|
||||
} = $props();
|
||||
|
||||
let reason = $state('');
|
||||
let note = $state('');
|
||||
let shownFor = $state<number | null>(null);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// The panel is reused as you click through candidates: load each variant's own decision.
|
||||
$effect(() => {
|
||||
if (shownFor === detail.variant.id) return;
|
||||
shownFor = detail.variant.id;
|
||||
reason = detail.decision?.reason ?? '';
|
||||
note = detail.decision?.note ?? '';
|
||||
});
|
||||
|
||||
const v = $derived(detail.variant);
|
||||
const links = $derived([
|
||||
{
|
||||
label: 'Ensembl',
|
||||
href: `https://www.ensembl.org/Homo_sapiens/Location/View?r=${v.chrom}:${v.pos}-${v.pos}`
|
||||
},
|
||||
{
|
||||
label: 'gnomAD',
|
||||
href: `https://gnomad.broadinstitute.org/region/${v.chrom}-${Math.max(1, v.pos - 25)}-${v.pos + 25}?dataset=gnomad_r4`
|
||||
},
|
||||
...(v.gene
|
||||
? [{ label: 'ClinVar', href: `https://www.ncbi.nlm.nih.gov/clinvar/?term=${encodeURIComponent(v.gene)}%5Bgene%5D` }]
|
||||
: [])
|
||||
]);
|
||||
|
||||
async function decide(state: DecisionState) {
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
await api.decide(v.id, { state, reason: reason || undefined, note: note || undefined });
|
||||
ondecided(await api.variant(v.id));
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="panel">
|
||||
<header class="panel-head">
|
||||
<h3>{v.gene ?? 'intergenic'} <span class="coord">{v.chrom}:{v.pos} {v.ref}>{v.alt}</span></h3>
|
||||
<button class="quiet" onclick={onclose} aria-label="Close panel">✕</button>
|
||||
</header>
|
||||
|
||||
<Chips chips={evidenceChips(detail, termCount)} />
|
||||
|
||||
<h4>Why it ranks {detail.score.toFixed(2)}</h4>
|
||||
<table class="components">
|
||||
<tbody>
|
||||
{#each Object.entries(detail.components) as [name, value] (name)}
|
||||
<tr>
|
||||
<th>{name}</th>
|
||||
<td class="coord">{value.toFixed(2)} × {(weights[name] ?? 0).toFixed(2)}</td>
|
||||
<td class="coord">{(value * (weights[name] ?? 0)).toFixed(3)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{#if detail.matched_terms.length}
|
||||
<h4>Phenotype match</h4>
|
||||
<p>HPO annotates <strong>{v.gene}</strong> with {detail.matched_terms.map((t) => t.label).join(', ')}.</p>
|
||||
{/if}
|
||||
|
||||
<h4>Evidence</h4>
|
||||
<dl class="evidence">
|
||||
<dt>Consequence</dt><dd>{v.consequence ?? '—'} <small class="muted">{v.impact ?? ''}</small></dd>
|
||||
<dt>HGVS</dt><dd class="hgvs">{v.hgvsp ?? v.hgvsc ?? '—'}</dd>
|
||||
<dt>gnomAD</dt><dd>{v.gnomad_af === null ? 'absent' : v.gnomad_af.toExponential(2)}</dd>
|
||||
<dt>ClinVar</dt><dd>{v.clinvar_sig ?? 'no submission'}</dd>
|
||||
<dt>Model</dt>
|
||||
<dd>{v.prediction ? `${v.prediction.score.toFixed(2)} (model ${v.prediction.model_version})` : 'unscored'}</dd>
|
||||
</dl>
|
||||
|
||||
<p class="links">
|
||||
{#each links as link (link.label)}<a href={link.href} target="_blank" rel="noreferrer">{link.label}</a>{/each}
|
||||
</p>
|
||||
|
||||
<h4>Decision</h4>
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
<input placeholder="Reason, e.g. fits the phenotype" bind:value={reason} aria-label="Reason" />
|
||||
<textarea placeholder="Note for the report" rows="3" bind:value={note} aria-label="Note"></textarea>
|
||||
<div class="decide">
|
||||
<button onclick={() => decide('shortlisted')} disabled={saving}>Shortlist</button>
|
||||
<button class="quiet" onclick={() => decide('dismissed')} disabled={saving}>Dismiss</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { variantQuery } from './query';
|
||||
|
||||
describe('variantQuery', () => {
|
||||
it('always includes the job id', () => {
|
||||
expect(variantQuery({ jobId: 'abc' })).toBe('job_id=abc');
|
||||
});
|
||||
|
||||
it('includes filters that have values', () => {
|
||||
const q = new URLSearchParams(
|
||||
variantQuery({ jobId: 'abc', gene: ' brca1 ', impact: 'HIGH', maxAf: 0.01, limit: 100 })
|
||||
);
|
||||
expect(q.get('gene')).toBe('brca1');
|
||||
expect(q.get('impact')).toBe('HIGH');
|
||||
expect(q.get('max_af')).toBe('0.01');
|
||||
expect(q.get('limit')).toBe('100');
|
||||
});
|
||||
|
||||
it('drops a cleared number input instead of sending "null"', () => {
|
||||
// Svelte binds an emptied <input type="number"> to null.
|
||||
for (const maxAf of [null, undefined, '', Number.NaN]) {
|
||||
expect(variantQuery({ jobId: 'abc', maxAf })).toBe('job_id=abc');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops blank gene and impact', () => {
|
||||
expect(variantQuery({ jobId: 'abc', gene: ' ', impact: '' })).toBe('job_id=abc');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
export type VariantFilters = {
|
||||
jobId: string;
|
||||
gene?: string;
|
||||
impact?: string;
|
||||
// An emptied <input type="number"> binds to null, so accept it and drop it.
|
||||
maxAf?: number | string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function variantQuery(f: VariantFilters): string {
|
||||
const q = new URLSearchParams({ job_id: f.jobId });
|
||||
const gene = f.gene?.trim();
|
||||
if (gene) q.set('gene', gene);
|
||||
if (f.impact) q.set('impact', f.impact);
|
||||
if (f.maxAf !== null && f.maxAf !== undefined && f.maxAf !== '' && Number.isFinite(Number(f.maxAf))) {
|
||||
q.set('max_af', String(f.maxAf));
|
||||
}
|
||||
if (f.limit !== undefined) q.set('limit', String(f.limit));
|
||||
if (f.offset !== undefined) q.set('offset', String(f.offset));
|
||||
return q.toString();
|
||||
}
|
||||
+57
-33
@@ -1,18 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Assembly, type Sample } from '$lib/api';
|
||||
import { api, type Assembly, type Case, type PhenotypeTerm } from '$lib/api';
|
||||
import PhenotypePicker from '$lib/components/PhenotypePicker.svelte';
|
||||
|
||||
let samples = $state<Sample[]>([]);
|
||||
let cases = $state<Case[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
let name = $state('');
|
||||
let vcfUri = $state('');
|
||||
let assembly = $state<Assembly>('GRCh38');
|
||||
let phenotypes = $state<PhenotypeTerm[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
samples = await api.samples();
|
||||
cases = await api.cases();
|
||||
loadError = null;
|
||||
} catch (e) {
|
||||
loadError = (e as Error).message;
|
||||
@@ -24,44 +26,66 @@
|
||||
|
||||
async function add() {
|
||||
error = null;
|
||||
try { await api.createSample({ name, vcf_uri: vcfUri, assembly }); name = ''; vcfUri = ''; await refresh(); }
|
||||
catch (e) { error = (e as Error).message; }
|
||||
try {
|
||||
await api.createCase({ name, vcf_uri: vcfUri, assembly, phenotypes });
|
||||
name = '';
|
||||
vcfUri = '';
|
||||
phenotypes = [];
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const status = (c: Case) => c.latest_job?.status ?? 'not analysed';
|
||||
</script>
|
||||
|
||||
<h1>rarelens</h1>
|
||||
<p class="lede">Annotate a VCF with Ensembl VEP, score each variant, and browse what came back. Public test data only.</p>
|
||||
<p class="lede">
|
||||
Rare disease triage on public data: a proband's variants narrowed against their phenotype, each
|
||||
candidate carrying the evidence for its rank. Research demo, not a diagnostic tool.
|
||||
</p>
|
||||
|
||||
<h2>Add a sample</h2>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
|
||||
<input placeholder="Sample name" bind:value={name} aria-label="Sample name" />
|
||||
<input placeholder="gs://bucket/sample.vcf.gz or /data/example.vcf.gz" bind:value={vcfUri} aria-label="VCF path" style="flex:1; min-width: 20rem" />
|
||||
<select bind:value={assembly} aria-label="Assembly">
|
||||
<option>GRCh38</option><option>GRCh37</option>
|
||||
</select>
|
||||
<button onclick={add} disabled={!name || !vcfUri}>Add sample</button>
|
||||
<h2>New case</h2>
|
||||
<div class="newcase">
|
||||
<div class="row">
|
||||
<input placeholder="Case name, e.g. PROBAND-01" bind:value={name} aria-label="Case name" />
|
||||
<input
|
||||
placeholder="gs://bucket/proband.vcf.gz or /data/proband.vcf.gz"
|
||||
bind:value={vcfUri}
|
||||
aria-label="VCF path"
|
||||
style="flex:1; min-width: 18rem" />
|
||||
<select bind:value={assembly} aria-label="Assembly">
|
||||
<option>GRCh38</option><option>GRCh37</option>
|
||||
</select>
|
||||
</div>
|
||||
<PhenotypePicker bind:selected={phenotypes} />
|
||||
<button onclick={add} disabled={!name || !vcfUri}>Create case</button>
|
||||
</div>
|
||||
{#if error}<p role="alert">Could not add the sample: {error}</p>{/if}
|
||||
{#if error}<p role="alert">Could not create the case: {error}</p>{/if}
|
||||
|
||||
<h2>Samples</h2>
|
||||
<h2>Cases</h2>
|
||||
{#if loadError}
|
||||
<p role="alert">Could not load samples: {loadError}. <button class="quiet" onclick={refresh}>Retry</button></p>
|
||||
<p role="alert">Could not load cases: {loadError}. <button class="quiet" onclick={refresh}>Retry</button></p>
|
||||
{:else if !loaded}
|
||||
<p style="color:var(--ink-soft)">Loading samples…</p>
|
||||
{:else if samples.length === 0}
|
||||
<div class="empty">No samples yet. Add one above to run the annotation pipeline.</div>
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if cases.length === 0}
|
||||
<div class="empty">No cases yet. Create one above, then run the annotation pipeline on it.</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>VCF</th><th>Assembly</th><th>Added</th></tr></thead>
|
||||
<tbody>
|
||||
{#each samples as s (s.id)}
|
||||
<tr>
|
||||
<td><a href="/samples/{s.id}">{s.name}</a></td>
|
||||
<td class="hgvs">{s.vcf_uri}</td>
|
||||
<td>{s.assembly}</td>
|
||||
<td>{new Date(s.created_at).toLocaleDateString('en-GB')}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="caselist">
|
||||
{#each cases as c (c.id)}
|
||||
<li>
|
||||
<a class="casename" href="/cases/{c.id}">{c.name}</a>
|
||||
<span class="chips">
|
||||
{#each c.phenotypes.slice(0, 4) as term (term.hpo_id)}
|
||||
<span class="chip match">{term.label}</span>
|
||||
{/each}
|
||||
{#if c.phenotypes.length > 4}<span class="chip muted">+{c.phenotypes.length - 4}</span>{/if}
|
||||
{#if c.phenotypes.length === 0}<span class="chip muted">no phenotype</span>{/if}
|
||||
</span>
|
||||
<span class="status {c.latest_job?.status ?? ''}">{status(c)}</span>
|
||||
<span class="muted">{c.shortlisted} shortlisted</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
|
||||
import { poll } from '$lib/poll';
|
||||
import { formatElapsed, latestStep } from '$lib/progress';
|
||||
import CandidateRow from '$lib/components/CandidateRow.svelte';
|
||||
import Funnel from '$lib/components/Funnel.svelte';
|
||||
import VariantPanel from '$lib/components/VariantPanel.svelte';
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed';
|
||||
|
||||
let { data } = $props();
|
||||
let kase = $state<Case | null>(null);
|
||||
let job = $state<Job | null>(null);
|
||||
let page = $state<CandidatePage | null>(null);
|
||||
let detail = $state<VariantDetail | null>(null);
|
||||
let gene = $state('');
|
||||
let impact = $state('');
|
||||
let maxAf = $state<number | null>(null);
|
||||
let decisionFilter = $state('');
|
||||
let busy = $state(false);
|
||||
let scoring = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let now = $state(Date.now());
|
||||
let stopPolling: (() => void) | null = null;
|
||||
|
||||
const running = $derived((!!job && !finished(job)) || scoring);
|
||||
const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0);
|
||||
const step = $derived(scoring ? 'scoring variants with the model' : latestStep(job?.log ?? null));
|
||||
const termCount = $derived(kase?.phenotypes.length ?? 0);
|
||||
|
||||
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
|
||||
$effect(() => {
|
||||
if (!running) return;
|
||||
const tick = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(tick);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
kase = await api.case(data.caseId);
|
||||
job = kase.latest_job;
|
||||
if (job && !finished(job)) follow(job);
|
||||
else await loadCandidates();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
});
|
||||
onDestroy(() => stopPolling?.());
|
||||
|
||||
function follow(j: Job) {
|
||||
stopPolling?.();
|
||||
stopPolling = poll(() => api.job(j.id), {
|
||||
intervalMs: POLL_MS,
|
||||
done: finished,
|
||||
onValue: (next) => {
|
||||
job = next;
|
||||
if (next.status === 'succeeded') scoreThenLoad();
|
||||
},
|
||||
onError: (e) => { error = `Lost track of the run: ${(e as Error).message}`; }
|
||||
});
|
||||
}
|
||||
|
||||
async function analyse() {
|
||||
busy = true;
|
||||
error = null;
|
||||
page = null;
|
||||
detail = null;
|
||||
try {
|
||||
job = await api.annotate(data.caseId);
|
||||
if (!finished(job)) follow(job);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Annotation and scoring are one action to the user; the API keeps them separate.
|
||||
async function scoreThenLoad() {
|
||||
scoring = true;
|
||||
try {
|
||||
await api.score(data.caseId);
|
||||
} catch (e) {
|
||||
error = `Scoring failed: ${(e as Error).message}`;
|
||||
} finally {
|
||||
scoring = false;
|
||||
}
|
||||
await loadCandidates();
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
try {
|
||||
page = await api.candidates(data.caseId, {
|
||||
gene,
|
||||
impact,
|
||||
maxAf,
|
||||
state: decisionFilter as never,
|
||||
limit: 100
|
||||
});
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function select(candidate: Candidate) {
|
||||
try {
|
||||
detail = await api.variant(candidate.variant.id);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function decided(updated: VariantDetail) {
|
||||
detail = updated;
|
||||
await loadCandidates();
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href="/">All cases</a>
|
||||
<div class="casehead">
|
||||
<h1>{kase?.name ?? 'Case'}</h1>
|
||||
<span class="chips">
|
||||
{#each kase?.phenotypes ?? [] as term (term.hpo_id)}
|
||||
<span class="chip match">{term.label}</span>
|
||||
{/each}
|
||||
{#if kase && kase.phenotypes.length === 0}
|
||||
<span class="chip muted">no phenotype recorded — ranking falls back to rarity and consequence</span>
|
||||
{/if}
|
||||
</span>
|
||||
<p class="muted provenance">
|
||||
{kase?.assembly}
|
||||
{#if job?.vep_version}· VEP {job.vep_version}{/if}
|
||||
{#if page?.items[0]?.variant.prediction}· model {page.items[0].variant.prediction.model_version}{/if}
|
||||
{#if job?.finished_at}· analysed {new Date(job.finished_at).toLocaleString('en-GB')}{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
|
||||
{#if running}
|
||||
<p class="progress" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
Analysing for {formatElapsed(elapsedMs)}{#if step} · <span class="hgvs">{step}</span>{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="actions">
|
||||
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
|
||||
{#if page && page.funnel.total > 0}
|
||||
<a class="reportlink" href="/cases/{data.caseId}/report">Case report →</a>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if job?.status === 'failed' && job.log}
|
||||
<pre class="log hgvs">{job.log}</pre>
|
||||
{/if}
|
||||
|
||||
{#if page && page.funnel.total > 0}
|
||||
<Funnel funnel={page.funnel} />
|
||||
|
||||
<div class="filters">
|
||||
<input placeholder="Gene" bind:value={gene} aria-label="Gene" />
|
||||
<select bind:value={impact} aria-label="Impact">
|
||||
<option value="">Any impact</option><option>HIGH</option><option>MODERATE</option>
|
||||
</select>
|
||||
<label>Max AF <input type="number" step="0.0001" min="0" max="1" bind:value={maxAf} style="width:6rem" /></label>
|
||||
<select bind:value={decisionFilter} aria-label="Decision">
|
||||
<option value="">All decisions</option>
|
||||
<option value="undecided">Undecided</option>
|
||||
<option value="shortlisted">Shortlisted</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
<button class="quiet" onclick={loadCandidates}>Apply</button>
|
||||
</div>
|
||||
|
||||
<div class="triage">
|
||||
<div class="candidates">
|
||||
<p class="muted">{page.total} candidates ranked by phenotype fit, rarity, consequence and model score</p>
|
||||
{#each page.items as candidate, i (candidate.variant.id)}
|
||||
<CandidateRow
|
||||
{candidate}
|
||||
rank={i + 1}
|
||||
{termCount}
|
||||
selected={detail?.variant.id === candidate.variant.id}
|
||||
onselect={() => select(candidate)} />
|
||||
{:else}
|
||||
<div class="empty">No candidates match these filters.</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if detail && page}
|
||||
<VariantPanel
|
||||
{detail}
|
||||
{termCount}
|
||||
weights={page.weights}
|
||||
onclose={() => (detail = null)}
|
||||
ondecided={decided} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if page && !running}
|
||||
<div class="empty">
|
||||
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
|
||||
left against the phenotype.
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { PageLoad } from './$types';
|
||||
export const load: PageLoad = ({ params }) => ({ caseId: params.id });
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Report } from '$lib/api';
|
||||
import Funnel from '$lib/components/Funnel.svelte';
|
||||
import Chips from '$lib/components/Chips.svelte';
|
||||
import { evidenceChips } from '$lib/candidates';
|
||||
|
||||
let { data } = $props();
|
||||
let report = $state<Report | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
report = await api.report(data.caseId);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
});
|
||||
|
||||
const termCount = $derived(report?.case.phenotypes.length ?? 0);
|
||||
</script>
|
||||
|
||||
<a class="noprint" href="/cases/{data.caseId}">← Back to triage</a>
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
|
||||
{#if report}
|
||||
<article class="report">
|
||||
<h1>Case report — {report.case.name}</h1>
|
||||
<p class="muted">
|
||||
Generated {new Date(report.generated_at).toLocaleString('en-GB')} ·
|
||||
{report.case.assembly}
|
||||
{#if report.provenance.vep_version}· VEP {report.provenance.vep_version}{/if}
|
||||
{#if report.provenance.model_version}· model {report.provenance.model_version}{/if}
|
||||
</p>
|
||||
|
||||
<h2>Phenotype</h2>
|
||||
<span class="chips">
|
||||
{#each report.case.phenotypes as term (term.hpo_id)}
|
||||
<span class="chip match">{term.label} <small class="muted">{term.hpo_id}</small></span>
|
||||
{:else}
|
||||
<span class="chip muted">none recorded</span>
|
||||
{/each}
|
||||
</span>
|
||||
|
||||
<h2>Narrowing</h2>
|
||||
<Funnel funnel={report.funnel} />
|
||||
|
||||
<h2>Shortlisted ({report.shortlisted.length})</h2>
|
||||
{#each report.shortlisted as item (item.variant.id)}
|
||||
<div class="reportitem">
|
||||
<h3>{item.variant.gene ?? 'intergenic'} <span class="coord">{item.variant.chrom}:{item.variant.pos} {item.variant.ref}>{item.variant.alt}</span></h3>
|
||||
<Chips chips={evidenceChips(item, termCount)} />
|
||||
<p><strong>Reason:</strong> {item.decision?.reason ?? '—'}</p>
|
||||
{#if item.decision?.note}<p class="muted">{item.decision.note}</p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">Nothing shortlisted yet.</p>
|
||||
{/each}
|
||||
|
||||
{#if report.dismissed.length}
|
||||
<h2>Dismissed ({report.dismissed.length})</h2>
|
||||
<ul class="dismissed">
|
||||
{#each report.dismissed as item (item.variant.id)}
|
||||
<li>
|
||||
<strong>{item.variant.gene ?? 'intergenic'}</strong>
|
||||
<span class="coord">{item.variant.chrom}:{item.variant.pos}</span>
|
||||
— {item.decision?.reason ?? 'no reason given'}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<p class="disclaimer">
|
||||
Research demonstration on public data. Computational evidence is supporting only under
|
||||
ACMG/AMP guidance and this tool makes no diagnostic claim.
|
||||
</p>
|
||||
</article>
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { PageLoad } from './$types';
|
||||
export const load: PageLoad = ({ params }) => ({ caseId: params.id });
|
||||
@@ -1,141 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { api, type Job, type VariantPage } from '$lib/api';
|
||||
import { poll } from '$lib/poll';
|
||||
import { formatElapsed, latestStep } from '$lib/progress';
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed';
|
||||
|
||||
let { data } = $props();
|
||||
let job = $state<Job | null>(null);
|
||||
let page = $state<VariantPage | null>(null);
|
||||
let gene = $state('');
|
||||
let impact = $state('');
|
||||
let maxAf = $state<number | null>(0.01);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let stopPolling: (() => void) | null = null;
|
||||
let now = $state(Date.now());
|
||||
|
||||
const running = $derived(!!job && !finished(job));
|
||||
const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0);
|
||||
const step = $derived(latestStep(job?.log ?? null));
|
||||
|
||||
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
|
||||
$effect(() => {
|
||||
if (!running) return;
|
||||
const tick = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(tick);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const jobs = await api.jobsForSample(data.sampleId); // newest first
|
||||
const latest = jobs[0] ?? null;
|
||||
const lastGood = jobs.find((j) => j.status === 'succeeded') ?? null;
|
||||
// Follow a run in progress; otherwise show the latest results, else the latest failure.
|
||||
job = latest && !finished(latest) ? latest : (lastGood ?? latest);
|
||||
if (job) await follow(job);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
});
|
||||
onDestroy(() => stopPolling?.());
|
||||
|
||||
async function follow(j: Job) {
|
||||
stopPolling?.();
|
||||
if (j.status === 'succeeded') return loadVariants();
|
||||
if (finished(j)) return;
|
||||
stopPolling = poll(() => api.job(j.id), {
|
||||
intervalMs: POLL_MS,
|
||||
done: finished,
|
||||
onValue: (next) => {
|
||||
job = next;
|
||||
if (next.status === 'succeeded') loadVariants();
|
||||
},
|
||||
onError: (e) => { error = `Lost track of the job: ${(e as Error).message}`; }
|
||||
});
|
||||
}
|
||||
|
||||
async function runAnnotation() {
|
||||
busy = true;
|
||||
error = null;
|
||||
page = null;
|
||||
try {
|
||||
job = await api.annotate(data.sampleId);
|
||||
await follow(job);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVariants() {
|
||||
if (!job) return;
|
||||
error = null;
|
||||
try {
|
||||
page = await api.variants({ jobId: job.id, gene, impact, maxAf, limit: 100 });
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const scoreClass = (s: number) => (s >= 0.8 ? 'score high' : s >= 0.5 ? 'score mid' : 'score');
|
||||
</script>
|
||||
|
||||
<a href="/">All samples</a>
|
||||
<h1>Sample {data.sampleId.slice(0, 8)}</h1>
|
||||
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
|
||||
{#if job}
|
||||
<p>Job <span class="hgvs">{job.id.slice(0, 8)}</span>: <span class="status {job.status}">{job.status}</span></p>
|
||||
{#if running}
|
||||
<p class="progress" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
Annotating for {formatElapsed(elapsedMs)}{#if step} · <span class="hgvs">{step}</span>{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if job.status === 'failed' && job.log}
|
||||
<pre class="hgvs" style="white-space:pre-wrap; background:white; border:1px solid var(--line); padding:0.75rem; max-height:16rem; overflow:auto">{job.log}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !job || job.status === 'failed'}
|
||||
<button onclick={runAnnotation} disabled={busy}>{job ? 'Run VEP annotation again' : 'Run VEP annotation'}</button>
|
||||
{/if}
|
||||
|
||||
{#if job?.status === 'succeeded'}
|
||||
<h2>Variants</h2>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-bottom:0.75rem">
|
||||
<input placeholder="Gene symbol" bind:value={gene} aria-label="Gene" />
|
||||
<select bind:value={impact} aria-label="Impact">
|
||||
<option value="">Any impact</option><option>HIGH</option><option>MODERATE</option><option>LOW</option><option>MODIFIER</option>
|
||||
</select>
|
||||
<label>Max gnomAD AF <input type="number" step="0.001" min="0" max="1" bind:value={maxAf} style="width:6rem" /></label>
|
||||
<button class="quiet" onclick={loadVariants}>Apply filters</button>
|
||||
</div>
|
||||
|
||||
{#if page && page.items.length === 0}
|
||||
<div class="empty">No variants match these filters. Raise the allele frequency cap or clear the gene filter.</div>
|
||||
{:else if page}
|
||||
<p style="color:var(--ink-soft)">{page.total} variants, showing {page.items.length}</p>
|
||||
<table>
|
||||
<thead><tr><th>Position</th><th>Gene</th><th>Consequence</th><th>HGVS</th><th>gnomAD AF</th><th>ClinVar</th><th>Score</th></tr></thead>
|
||||
<tbody>
|
||||
{#each page.items as v (v.id)}
|
||||
<tr>
|
||||
<td class="coord">{v.chrom}:{v.pos} {v.ref}>{v.alt}</td>
|
||||
<td>{v.gene ?? ''}</td>
|
||||
<td>{v.consequence ?? ''}<br /><small style="color:var(--ink-soft)">{v.impact ?? ''}</small></td>
|
||||
<td class="hgvs">{v.hgvsp ?? v.hgvsc ?? ''}</td>
|
||||
<td>{v.gnomad_af?.toExponential(2) ?? 'absent'}</td>
|
||||
<td>{v.clinvar_sig ?? ''}</td>
|
||||
<td>{#if v.prediction}<span class={scoreClass(v.prediction.score)}>{v.prediction.score.toFixed(2)}</span>{:else}<span style="color:var(--ink-soft)">unscored</span>{/if}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,2 +0,0 @@
|
||||
import type { PageLoad } from './$types';
|
||||
export const load: PageLoad = ({ params }) => ({ sampleId: params.id });
|
||||
Reference in New Issue
Block a user