diff --git a/rust/crates/du-db/src/variant.rs b/rust/crates/du-db/src/variant.rs index 213aecbe..46999b37 100644 --- a/rust/crates/du-db/src/variant.rs +++ b/rust/crates/du-db/src/variant.rs @@ -233,6 +233,62 @@ pub async fn set_coordinates_batch( Ok(affected) } +/// One tree branch a variant is assigned to (defines). Surfaced on the Variant Browser detail. +#[derive(Debug, Clone)] +pub struct BranchAssignment { + /// Haplogroup (branch) name, e.g. `R-M269`. + pub haplogroup_name: String, + /// `"Y_DNA"` or `"MT_DNA"` — selects the tree page for the link. + pub dna_type: String, + /// The link is marked provisional/low-confidence. + pub low_confidence: bool, + /// The defining variant's canonical name. Differs from the viewed variant when the tree + /// placement lives on a same-site sibling row (the de-novo coordinate-named variant vs a + /// separately-named catalog row). + pub via_name: String, +} + +/// The tree branch(es) a variant is assigned to (defines) in the *current* tree. +/// +/// Matched by genomic **site**, not just the row's own links: the de-novo tree places a +/// coordinate-named variant (`chrY:>>...`) while the same physical SNP may also exist +/// as a separately-named catalog row (e.g. a YBrowse name like `V3739`) carrying no direct +/// link. `site` is a `{"hs1":{"contig":…,"position":…}}` containment blob built from the +/// viewed variant's hs1 coordinate (the tree is hs1-native) — GIN-indexed on `coordinates` — +/// or `None` when it has no hs1 coordinate (then only the variant's own direct links match). +pub async fn tree_branches( + pool: &PgPool, + variant_id: VariantId, + site: Option, +) -> Result, DbError> { + let rows: Vec<(String, String, bool, String)> = sqlx::query_as( + "WITH cand AS ( \ + SELECT $1::bigint AS id \ + UNION \ + SELECT id FROM core.variant WHERE $2::jsonb IS NOT NULL AND coordinates @> $2::jsonb \ + ) \ + SELECT DISTINCT h.name, h.haplogroup_type::text, hv.low_confidence, def.canonical_name \ + FROM cand \ + JOIN tree.haplogroup_variant hv ON hv.variant_id = cand.id AND hv.valid_until IS NULL \ + JOIN core.variant def ON def.id = cand.id \ + JOIN tree.haplogroup h ON h.id = hv.haplogroup_id \ + ORDER BY h.name", + ) + .bind(variant_id.0) + .bind(site) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(haplogroup_name, dna_type, low_confidence, via_name)| BranchAssignment { + haplogroup_name, + dna_type, + low_confidence, + via_name, + }) + .collect()) +} + /// Region types whose sequence is structurally unreliable for Y-SNP placement /// (multi-copy / repeat-rich), so a variant landing inside one should not be /// trusted as branch-defining without scrutiny. AZF intervals are deliberately diff --git a/rust/crates/du-web/src/routes/variants.rs b/rust/crates/du-web/src/routes/variants.rs index b1621bda..20c67749 100644 --- a/rust/crates/du-web/src/routes/variants.rs +++ b/rust/crates/du-web/src/routes/variants.rs @@ -100,6 +100,17 @@ struct CoordView { change: Option, } +/// A tree branch this variant defines, flattened for the template. +struct BranchView { + name: String, + /// Link to the branch on the relevant tree page. + tree_url: String, + low_confidence: bool, + /// The defining variant's name, shown only when it differs from the viewed variant + /// (the placement is carried by a same-site sibling row). + via: Option, +} + #[derive(askama::Template)] #[template(path = "variants/detail.html")] struct DetailTemplate { @@ -110,6 +121,7 @@ struct DetailTemplate { common_names: Vec, rs_ids: Vec, coords: Vec, + branches: Vec, } async fn browser( @@ -156,6 +168,28 @@ async fn detail( .collect(); coords.sort_by(|a, b| a.build.cmp(&b.build)); + // Which tree branch(es) this SNP is assigned to. Match by hs1 site (the tree is + // hs1-native), so a catalog-named SNP still surfaces the placement its de-novo + // coordinate-named sibling carries. + let site = v + .coordinates + .get(du_domain::enums::ReferenceBuild::Hs1) + .map(|c| serde_json::json!({ "hs1": { "contig": c.contig, "position": c.position } })); + let variant_name = v.canonical_name.clone(); + let branches = du_db::variant::tree_branches(&st.pool, VariantId(id), site) + .await? + .into_iter() + .map(|b| { + let base = if b.dna_type == "MT_DNA" { "/mtree" } else { "/ytree" }; + BranchView { + tree_url: format!("{base}?root={}", b.haplogroup_name), + via: (b.via_name != variant_name).then_some(b.via_name), + name: b.haplogroup_name, + low_confidence: b.low_confidence, + } + }) + .collect(); + Ok(html(&DetailTemplate { t: locale.t, name: v.canonical_name, @@ -164,5 +198,6 @@ async fn detail( common_names: v.aliases.common_names, rs_ids: v.aliases.rs_ids, coords, + branches, })) } diff --git a/rust/crates/du-web/templates/variants/detail.html b/rust/crates/du-web/templates/variants/detail.html index 45c381c2..a05f2d15 100644 --- a/rust/crates/du-web/templates/variants/detail.html +++ b/rust/crates/du-web/templates/variants/detail.html @@ -41,5 +41,20 @@
{{ t.get("variants.detail.coordinates") }}
{% endif %} + +
{{ t.get("variants.detail.branches") }}
+ {% if branches.is_empty() %} +

{{ t.get("variants.detail.nobranches") }}

+ {% else %} +
    + {% for b in branches %} +
  • + {{ b.name }} + {% if b.low_confidence %}{{ t.get("variants.detail.lowconf") }}{% endif %} + {% if let Some(via) = b.via %}{{ t.get("variants.detail.via") }} {{ via }}{% endif %} +
  • + {% endfor %} +
+ {% endif %} diff --git a/rust/locales/en.txt b/rust/locales/en.txt index c3f9fce5..0e8a9d44 100644 --- a/rust/locales/en.txt +++ b/rust/locales/en.txt @@ -106,6 +106,10 @@ variants.detail.aka=Also known as: variants.detail.rsids=rs IDs: variants.detail.coordinates=Coordinates variants.detail.nocoords=No mapped coordinates. +variants.detail.branches=Tree branch(es) +variants.detail.nobranches=Not assigned to a tree branch. +variants.detail.via=via +variants.detail.lowconf=provisional variants.col.build=Build variants.col.contig=Contig variants.col.position=Position diff --git a/rust/locales/es.txt b/rust/locales/es.txt index 01829327..80422e0d 100644 --- a/rust/locales/es.txt +++ b/rust/locales/es.txt @@ -58,6 +58,10 @@ variants.detail.aka=También conocido como: variants.detail.rsids=Identificadores rs: variants.detail.coordinates=Coordenadas variants.detail.nocoords=Sin coordenadas asignadas. +variants.detail.branches=Rama(s) del árbol +variants.detail.nobranches=No asignado a ninguna rama del árbol. +variants.detail.via=vía +variants.detail.lowconf=provisional variants.col.build=Ensamblaje variants.col.contig=Contig variants.col.position=Posición diff --git a/rust/locales/fr.txt b/rust/locales/fr.txt index cc405ce8..cd38cf05 100644 --- a/rust/locales/fr.txt +++ b/rust/locales/fr.txt @@ -58,6 +58,10 @@ variants.detail.aka=Aussi connu sous le nom de : variants.detail.rsids=Identifiants rs : variants.detail.coordinates=Coordonnées variants.detail.nocoords=Aucune coordonnée cartographiée. +variants.detail.branches=Branche(s) de l'arbre +variants.detail.nobranches=Non assigné à une branche de l'arbre. +variants.detail.via=via +variants.detail.lowconf=provisoire variants.col.build=Assemblage variants.col.contig=Contig variants.col.position=Position diff --git a/rust/migrations/0055_haplogroup_variant_variant_idx.sql b/rust/migrations/0055_haplogroup_variant_variant_idx.sql new file mode 100644 index 00000000..bae43cf4 --- /dev/null +++ b/rust/migrations/0055_haplogroup_variant_variant_idx.sql @@ -0,0 +1,9 @@ +-- Index tree.haplogroup_variant by variant_id for the *current* (valid_until IS NULL) links. +-- +-- The existing haplogroup_variant_current_key is UNIQUE (haplogroup_id, variant_id) — it leads +-- with haplogroup_id, so the reverse lookup "which branch(es) is this variant assigned to?" has +-- no supporting index and seq-scans the whole table. That lookup backs the Variant Browser's +-- tree-branch panel (site-matched, so it runs per variant-detail view), plus any future +-- variant->branch surfacing. Partial (valid_until IS NULL) to match the current-links workload. +CREATE INDEX IF NOT EXISTS haplogroup_variant_variant_idx + ON tree.haplogroup_variant (variant_id) WHERE valid_until IS NULL;