Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions backend/src/monarch_py/utils/association_type_mappings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,22 @@
category: "biolink:GenotypeToDiseaseAssociation"
subject_category: "biolink:Genotype"
object_category: "biolink:Disease"
- subject_label: Medical Action
object_label: Medical Action
category: "biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation"
# Note: subject_category/object_category omitted - this association uses disease_context_qualifier
# and requires special 1-hop handling that isn't yet implemented in the entity grid
# Drug indications / treatments. Combines MEDIC (biolink:treats, FDA-approved,
# text-mined) and CTD (biolink:treats_or_applied_or_studied_to_treat, curated),
# which carry different association categories, into one section via the
# multivalued category criterion. Shown as "Indications" on a drug/chemical page
# and "Treatments" on a disease/phenotype page.
- key: drug_indications
subject_label: Indications
object_label: Treatments
category:
- "biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation"
- "biolink:ChemicalEntityToDiseaseOrPhenotypicFeatureAssociation"
subject_category:
- "biolink:ChemicalEntity"
object_category:
- "biolink:Disease"
- "biolink:PhenotypicFeature"
- subject_label: Variant to Phenotype
object_label: Variant to Phenotype
category: "biolink:VariantToPhenotypicFeatureAssociation"
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/fixtures/association_counts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

@pytest.fixture
def association_counts():
return {'items': [{'label': 'Disease Model', 'count': 246, 'key': 'biolink:GenotypeToDiseaseAssociation', 'category': 'biolink:GenotypeToDiseaseAssociation', 'count_direct': 14, 'count_with_orthologs': None}, {'label': 'Disease to Phenotype', 'count': 4247, 'key': 'biolink:DiseaseToPhenotypicFeatureAssociation', 'category': 'biolink:DiseaseToPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Causal Gene', 'count': 133, 'key': 'biolink:CausalGeneToDiseaseAssociation', 'category': 'biolink:CausalGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Correlated Gene', 'count': 156, 'key': 'biolink:CorrelatedGeneToDiseaseAssociation', 'category': 'biolink:CorrelatedGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Variant to Disease', 'count': 701, 'key': 'biolink:VariantToDiseaseAssociation', 'category': 'biolink:VariantToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Medical Action', 'count': 6, 'key': 'biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation', 'category': 'biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Cases', 'count': 136, 'key': 'biolink:CaseToDiseaseAssociation', 'category': 'biolink:CaseToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}]}
return {'items': [{'label': 'Disease Model', 'count': 246, 'key': 'biolink:GenotypeToDiseaseAssociation', 'category': 'biolink:GenotypeToDiseaseAssociation', 'count_direct': 14, 'count_with_orthologs': None}, {'label': 'Disease to Phenotype', 'count': 4247, 'key': 'biolink:DiseaseToPhenotypicFeatureAssociation', 'category': 'biolink:DiseaseToPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Causal Gene', 'count': 133, 'key': 'biolink:CausalGeneToDiseaseAssociation', 'category': 'biolink:CausalGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Correlated Gene', 'count': 156, 'key': 'biolink:CorrelatedGeneToDiseaseAssociation', 'category': 'biolink:CorrelatedGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Variant to Disease', 'count': 701, 'key': 'biolink:VariantToDiseaseAssociation', 'category': 'biolink:VariantToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Treatments', 'count': 6, 'key': 'drug_indications', 'category': 'biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Cases', 'count': 136, 'key': 'biolink:CaseToDiseaseAssociation', 'category': 'biolink:CaseToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}]}
2 changes: 1 addition & 1 deletion backend/tests/fixtures/association_counts_query.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/tests/fixtures/association_counts_response.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions docs/sources-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ sources:
- repo: monarch-initiative/maxo-annotation-ingest
branch: main
filename: maxo.md
- repo: monarch-initiative/medic-ingest
branch: main
filename: medic.md
- repo: monarch-initiative/mmrrc-ingest
branch: main
filename: mmrrc.md
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/composables/use-association-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { startCase } from "lodash";
import { TRAVERSE_ORTHOLOG_CATEGORIES } from "@/api/associations";
import type { Node } from "@/api/model";

const HIDDEN_CATEGORIES = new Set([
"biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation",
]);
/** section keys (ac.key) to hide from node pages */
const HIDDEN_CATEGORIES = new Set<string>([]);

export function useAssociationCategories(node: Node) {
const options = computed(() => {
Expand Down
114 changes: 99 additions & 15 deletions frontend/src/pages/node/AssociationsTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ import {
import { getBreadcrumbs } from "@/pages/node/AssociationsSummary.vue";
import SectionAssociationDetails from "@/pages/node/SectionAssociationDetails.vue";
import { getAgentTypeMeta } from "@/util/agentType";
import { isPredicateFilterable } from "@/util/predicateFilterConfig";
import { taxonFieldFor } from "@/util/taxonFilterConfig";
import { fieldFor } from "@/util/typeConfig";

Expand All @@ -246,6 +247,8 @@ type Props = {
search: string;
/** selected taxon labels to filter by */
taxonFilters?: string[];
/** selected predicates to filter by */
predicateFilters?: string[];
};

const props = defineProps<Props>();
Expand All @@ -264,6 +267,10 @@ const emit = defineEmits<{
e: "taxon-options",
options: { id: string; label: string; count: number }[],
): void;
(
e: "predicate-options",
options: { id: string; label: string; count: number }[],
): void;
}>();

const shouldTraverseOrthologs = computed(() =>
Expand All @@ -273,16 +280,33 @@ const shouldTraverseOrthologs = computed(() =>
/** the single taxon field to facet/filter on for this category */
const taxonField = computed(() => taxonFieldFor(props.category.id));

/** facet fields to request when taxon filtering is enabled */
const facetFields = computed(() =>
taxonField.value ? [taxonField.value] : undefined,
/** whether this section offers a predicate filter */
const predicateFilterable = computed(() =>
isPredicateFilterable(props.category.id),
);

/** build filter queries from selected taxon labels */
/** facet fields to request for the enabled filters */
const facetFields = computed(() => {
const fields: string[] = [];
if (taxonField.value) fields.push(taxonField.value);
if (predicateFilterable.value) fields.push("predicate");
return fields.length ? fields : undefined;
});

/** build filter queries from the selected taxon and predicate filters */
const filterQueries = computed(() => {
if (!props.taxonFilters?.length || !taxonField.value) return undefined;
const clauses = props.taxonFilters.map((t) => `${taxonField.value}:"${t}"`);
return [clauses.join(" OR ")];
const queries: string[] = [];
if (props.taxonFilters?.length && taxonField.value) {
queries.push(
props.taxonFilters.map((t) => `${taxonField.value}:"${t}"`).join(" OR "),
);
}
if (props.predicateFilters?.length) {
queries.push(
props.predicateFilters.map((p) => `predicate:"${p}"`).join(" OR "),
);
}
return queries.length ? queries : undefined;
});

/** track last emitted taxon options to avoid redundant emits */
Expand Down Expand Up @@ -313,6 +337,44 @@ function emitTaxonOptions(facetFieldsData?: FacetField[]) {
emit("taxon-options", options);
}

/** track last emitted predicate options to avoid redundant emits */
let lastPredicateOptionsKey = "";

/**
* Emit predicate options from the union of the direct and inferred datasets'
* predicate facets, so the filter toggle reflects the whole section regardless
* of the active tab. (A predicate can appear in one set but not the other —
* e.g. biolink:treats in the direct set while the weaker CTD predicate only
* shows up in the inferred/all set — and the checkbox must not depend on which
* tab loads first.)
*/
function emitPredicateOptions() {
if (!predicateFilterable.value) return;

/** see emitTaxonOptions: only build options from unfiltered queries */
if (props.predicateFilters?.length) return;

const counts = new Map<string, number>();
for (const data of [directData.value, allData.value]) {
const field = data?.facet_fields?.find((f) => f.label === "predicate");
for (const fv of field?.facet_values ?? []) {
if (!fv.label) continue;
counts.set(fv.label, Math.max(counts.get(fv.label) ?? 0, fv.count ?? 0));
}
}
if (!counts.size) return;

const options = [...counts.entries()]
.map(([label, count]) => ({ id: label, label, count }))
.sort((a, b) => b.count - a.count);

const key = JSON.stringify(options);
if (key === lastPredicateOptionsKey) return;
lastPredicateOptionsKey = key;

emit("predicate-options", options);
}

function openModal(association: DirectionalAssociation) {
selectedAssociation.value = association;
showModal.value = true;
Expand Down Expand Up @@ -582,16 +644,26 @@ watch([perPage, sort, start], async () => {
}
});

/** refetch when taxon filters change (compare by value, not reference) */
/**
* refetch when a filter changes (compare by value, not reference). Both the
* direct and inferred/all datasets are refetched so either tab reflects the
* current filter — otherwise switching tabs would show the other dataset's
* mount-time, unfiltered result (e.g. the inferred treatments tab not matching
* the indications checkbox).
*/
watch(
() => JSON.stringify(props.taxonFilters ?? []),
async () => {
start.value = 0;
if (props.direct.id === "true") {
await fetchDirect();
} else {
await fetchAll();
}
await Promise.all([fetchDirect(), fetchAll()]);
},
);

watch(
() => JSON.stringify(props.predicateFilters ?? []),
async () => {
start.value = 0;
await Promise.all([fetchDirect(), fetchAll()]);
},
);

Expand All @@ -600,13 +672,25 @@ onMounted(async () => {
await Promise.all([fetchDirect(), fetchAll()]);
});

/** emit taxon options whenever data changes */
/** emit taxon options from the active dataset whenever it changes */
watch(
() =>
props.direct.id === "true"
? directData.value?.facet_fields
: allData.value?.facet_fields,
(facetFieldsData) => emitTaxonOptions(facetFieldsData),
(facetFieldsData) => {
emitTaxonOptions(facetFieldsData);
},
{ immediate: true },
);

/**
* emit predicate options from BOTH datasets (union) so the filter toggle is
* independent of which tab is active or loads first
*/
watch(
() => [directData.value?.facet_fields, allData.value?.facet_fields],
() => emitPredicateOptions(),
{ immediate: true },
);

Expand Down
Loading