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:
Kemal Yaylali
2026-09-12 08:30:44 +01:00
parent abde5ec6e4
commit 07a01715fd
47 changed files with 2159 additions and 539 deletions
+87 -1
View File
@@ -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
View File
@@ -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)}`)
};
+88
View File
@@ -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');
});
});
+70
View File
@@ -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}&gt;{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>
+10
View File
@@ -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>
+18
View File
@@ -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>
+111
View File
@@ -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}&gt;{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>
-29
View File
@@ -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');
});
});
-22
View File
@@ -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
View File
@@ -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}
+206
View File
@@ -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}
+2
View File
@@ -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}&gt;{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 });
-141
View File
@@ -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}
-2
View File
@@ -1,2 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => ({ sampleId: params.id });