From 07ddf1cf4bc02104c4fe01e3781eeab7829f5c29 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Tue, 14 Apr 2026 18:52:29 -0400 Subject: [PATCH 01/19] Add new digital objects route --- apps/kg-explorer/src/app/app.routes.ts | 9 +++-- .../app/digital-objects-metadata.schema.ts | 38 +++++++++++++++++++ .../pages/main-page/main-page.component.ts | 6 +-- .../metadata-page/metadata-page.component.ts | 27 ++++++++----- apps/kg-explorer/src/app/utils/kg-resolver.ts | 13 +------ apps/kg-explorer/src/app/utils/utils.ts | 2 +- 6 files changed, 67 insertions(+), 28 deletions(-) diff --git a/apps/kg-explorer/src/app/app.routes.ts b/apps/kg-explorer/src/app/app.routes.ts index 91008bb9c8..7ab9a3f4f7 100644 --- a/apps/kg-explorer/src/app/app.routes.ts +++ b/apps/kg-explorer/src/app/app.routes.ts @@ -3,6 +3,8 @@ import { NotFoundPageComponent } from '@hra-ui/design-system/error-pages/not-fou import { ServerErrorPageComponent } from '@hra-ui/design-system/error-pages/server-error-page'; import { TableColumn } from '@hra-ui/design-system/table'; +import { createJsonSpecResolver } from '@hra-ui/design-system/content-templates/resolvers'; +import { DigitalObjectsJsonLdSchema } from './digital-objects-metadata.schema'; import { MainPageComponent } from './pages/main-page/main-page.component'; import { MetadataPageComponent } from './pages/metadata-page/metadata-page.component'; import { @@ -11,7 +13,6 @@ import { cellTypeResolver, documentationUrlResolver, doMetadataResolver, - kgResolver, ontologyResolver, productLabelResolver, } from './utils/kg-resolver'; @@ -100,6 +101,8 @@ export interface HelpMenuOptions { icon?: string; } +const DO_URL = 'https://cdn.humanatlas.io/digital-objects/kg/digital-objects.jsonld'; + /** Application routes */ export const appRoutes: Route[] = [ { @@ -111,7 +114,7 @@ export const appRoutes: Route[] = [ columns: DO_COLUMNS, }, resolve: { - data: kgResolver(), + data: createJsonSpecResolver(DO_URL, DigitalObjectsJsonLdSchema), asctbTermOccurrences: asctbResolver(), ontologyTree: ontologyResolver(), cellTypeTree: cellTypeResolver(), @@ -125,7 +128,7 @@ export const appRoutes: Route[] = [ columns: METADATA_COLUMNS, }, resolve: { - doData: kgResolver(), + doData: createJsonSpecResolver(DO_URL, DigitalObjectsJsonLdSchema), metadata: doMetadataResolver(), documentationUrl: documentationUrlResolver(), typeLabel: productLabelResolver(), diff --git a/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts b/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts index 8c26eea7d4..ada707c1e7 100644 --- a/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts +++ b/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts @@ -1,3 +1,4 @@ +import { DigitalObjectInfoTypeEnum } from '@hra-api/ng-client'; import * as z from 'zod'; /** Person info type */ @@ -81,3 +82,40 @@ export const DigitalObjectMetadataSchema = z }), }) .meta({ id: 'DigitalObjectMetadata' }); + +/** Digital object info type */ +export type DigitalObjectInfo = z.infer; + +/** Digital object schema */ +export const DigitalObjectInfoSchema = z + .object({ + '@id': z.string(), + '@type': z.enum(DigitalObjectInfoTypeEnum), + title: z.string(), + doType: z.string(), + doName: z.string(), + doVersion: z.string(), + lastUpdated: z.object({ + '@type': z.string(), + '@value': z.string(), + }), + hraVersions: z.union([z.string(), z.string().array()]).optional(), + versions: z.union([z.string(), z.string().array()]), + purl: z.string(), + datasets: z.union([z.string(), z.string().array()]), + lod: z.string(), + cell_count: z.string().optional(), + biomarker_count: z.string().optional(), + organs: z.union([z.string(), z.string().array()]).optional(), + organIds: z.union([z.string(), z.string().array()]).optional(), + }) + .meta({ id: 'DigitalObjectInfo' }); + +export type DigitalObjectsJsonLd = z.infer; + +export const DigitalObjectsJsonLdSchema = z + .object({ + '@context': z.record(z.string(), z.any()), + '@graph': z.array(DigitalObjectInfoSchema), + }) + .meta({ id: 'DigitalObjectsJsonLd' }); diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index b520729b44..589e3f4739 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -6,7 +6,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSidenavModule } from '@angular/material/sidenav'; import { ActivatedRoute, Params, Router } from '@angular/router'; -import { DigitalObjectInfo, DigitalObjectsJsonLd, HraKgService, OntologyTree } from '@hra-api/ng-client'; +import { HraKgService, OntologyTree } from '@hra-api/ng-client'; import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; import { HraCommonModule } from '@hra-ui/common'; import { BrandModule } from '@hra-ui/design-system/brand'; @@ -18,7 +18,7 @@ import { TableColumn, TableComponent, TableRow } from '@hra-ui/design-system/tab import { fromEvent, Observable } from 'rxjs'; import { FilterFormValues, FilterMenuComponent } from '../../components/filter-menu/filter-menu.component'; -import { DigitalObjectMetadata } from '../../digital-objects-metadata.schema'; +import { DigitalObjectInfo, DigitalObjectsJsonLd, DigitalObjectMetadata } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; import { FILTER_CATEGORY_INFO, @@ -501,7 +501,7 @@ export class MainPageComponent { organTooltip: sentenceCase(organLabel || 'All Organs'), cellCount: item.cell_count, biomarkerCount: item.biomarker_count, - lastPublished: this.formatDateToYYYYMM(item.lastUpdated), + lastPublished: this.formatDateToYYYYMM(item.lastUpdated['@value']), } as TableRow; }); } diff --git a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts index 172d6208c4..b64da2774f 100644 --- a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts +++ b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts @@ -4,7 +4,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, effect, inject, input, signal } from import { toObservable } from '@angular/core/rxjs-interop'; import { MatChipsModule } from '@angular/material/chips'; import { ActivatedRoute, Router } from '@angular/router'; -import { DigitalObjectsJsonLd, V1Service } from '@hra-api/ng-client'; +import { V1Service } from '@hra-api/ng-client'; import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; import { HraCommonModule } from '@hra-ui/common'; import { PageSectionComponent } from '@hra-ui/design-system/content-templates/page-section'; @@ -15,7 +15,7 @@ import { MarkdownComponent } from 'ngx-markdown'; import { MetadataLayoutModule } from '../../components/metadata-layout/metadata-layout.module'; import { ProvenanceMenuComponent } from '../../components/provenance-menu/provenance-menu.component'; -import { DigitalObjectMetadata, PersonInfo } from '../../digital-objects-metadata.schema'; +import { DigitalObjectsJsonLd, DigitalObjectMetadata, PersonInfo } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; import { getOrganIcon, getProductIcon, getProductLabel, sentenceCase } from '../../utils/utils'; @@ -142,14 +142,23 @@ export class MetadataPageComponent { this.v1.ontologyTreeModel({}).subscribe((ontologyData) => { if (pageItem) { - this.availableVersions.set(pageItem.versions); + if (Array.isArray(pageItem.versions)) { + this.availableVersions.set(pageItem.versions); + } else { + this.availableVersions.set([pageItem.versions]); + } const tags = [{ id: type, label: getProductLabel(type), type: 'do' }]; - for (const organId of pageItem.organIds || []) { - tags.push({ - id: organId, - label: sentenceCase(ontologyData.nodes[organId].label || ''), - type: 'organs', - }); + if (pageItem.organIds) { + const ids = Array.isArray(pageItem.organIds) ? pageItem.organIds : [pageItem.organIds]; + for (const organId of ids) { + if (ontologyData.nodes[organId]) { + tags.push({ + id: organId, + label: sentenceCase(ontologyData.nodes[organId].label || ''), + type: 'organs', + }); + } + } } this.tags.set(tags); } diff --git a/apps/kg-explorer/src/app/utils/kg-resolver.ts b/apps/kg-explorer/src/app/utils/kg-resolver.ts index 07a4fc3fd6..c5e8e44348 100644 --- a/apps/kg-explorer/src/app/utils/kg-resolver.ts +++ b/apps/kg-explorer/src/app/utils/kg-resolver.ts @@ -1,23 +1,12 @@ import { HttpClient } from '@angular/common/http'; import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; -import { DigitalObjectsJsonLd, HraKgService, OntologyTree, V1Service } from '@hra-api/ng-client'; +import { HraKgService, OntologyTree, V1Service } from '@hra-api/ng-client'; import { catchError, map, of } from 'rxjs'; import { DigitalObjectMetadata } from '../digital-objects-metadata.schema'; import { injectMirrorUrl } from './endpoints'; import { getDocumentationUrl, getProductLabel } from './utils'; -/** - * Creates a resolver that fetches the digital object data from a url - * @returns Resolver - */ -export function kgResolver(): ResolveFn { - return () => { - const kg = inject(HraKgService); - return kg.digitalObjects(); - }; -} - /** * Creates a resolver for digital object metadata from the current route * @returns Resolver diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index 9442761aa7..c4b48f0b59 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -1,4 +1,4 @@ -import { DigitalObjectInfo } from '@hra-api/ng-client'; +import { DigitalObjectInfo } from '../digital-objects-metadata.schema'; /** Tooltip data interface */ export interface TooltipData { From defe2394bb25503189b1ade479835bbcdc2fad10 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 15 Apr 2026 16:37:15 -0400 Subject: [PATCH 02/19] Create filter service --- apps/kg-explorer/src/app/app.routes.ts | 14 +- .../app/digital-objects-metadata.schema.ts | 22 +++ .../pages/main-page/main-page.component.html | 6 +- .../pages/main-page/main-page.component.ts | 169 +++++++++++------- .../src/app/services/filter.service.ts | 36 ++++ apps/kg-explorer/src/app/utils/utils.ts | 18 +- 6 files changed, 191 insertions(+), 74 deletions(-) create mode 100644 apps/kg-explorer/src/app/services/filter.service.ts diff --git a/apps/kg-explorer/src/app/app.routes.ts b/apps/kg-explorer/src/app/app.routes.ts index 7ab9a3f4f7..747df46f70 100644 --- a/apps/kg-explorer/src/app/app.routes.ts +++ b/apps/kg-explorer/src/app/app.routes.ts @@ -4,7 +4,7 @@ import { ServerErrorPageComponent } from '@hra-ui/design-system/error-pages/serv import { TableColumn } from '@hra-ui/design-system/table'; import { createJsonSpecResolver } from '@hra-ui/design-system/content-templates/resolvers'; -import { DigitalObjectsJsonLdSchema } from './digital-objects-metadata.schema'; +import { AsctbTermsSchema, DigitalObjectsJsonLdSchema, TermsIndexSchema } from './digital-objects-metadata.schema'; import { MainPageComponent } from './pages/main-page/main-page.component'; import { MetadataPageComponent } from './pages/metadata-page/metadata-page.component'; import { @@ -102,6 +102,8 @@ export interface HelpMenuOptions { } const DO_URL = 'https://cdn.humanatlas.io/digital-objects/kg/digital-objects.jsonld'; +const ASCTB_TERMS_URL = 'https://cdn.humanatlas.io/digital-objects/kg/asctb-terms.json'; +const KG_TERMS_INDEX_URL = 'https://cdn.humanatlas.io/digital-objects/kg/kg-terms-index.json'; /** Application routes */ export const appRoutes: Route[] = [ @@ -115,10 +117,12 @@ export const appRoutes: Route[] = [ }, resolve: { data: createJsonSpecResolver(DO_URL, DigitalObjectsJsonLdSchema), - asctbTermOccurrences: asctbResolver(), - ontologyTree: ontologyResolver(), - cellTypeTree: cellTypeResolver(), - biomarkerTree: biomarkersResolver(), + asctbTerms: createJsonSpecResolver(ASCTB_TERMS_URL, AsctbTermsSchema), + termsIndex: createJsonSpecResolver(KG_TERMS_INDEX_URL, TermsIndexSchema), + // asctbTermOccurrences: asctbResolver(), + // ontologyTree: ontologyResolver(), + // cellTypeTree: cellTypeResolver(), + // biomarkerTree: biomarkersResolver(), }, }, { diff --git a/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts b/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts index ada707c1e7..e816a7dc45 100644 --- a/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts +++ b/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts @@ -119,3 +119,25 @@ export const DigitalObjectsJsonLdSchema = z '@graph': z.array(DigitalObjectInfoSchema), }) .meta({ id: 'DigitalObjectsJsonLd' }); + +export type AsctbTerms = z.infer; + +export const AsctbTermsSchema = z + .object({ + asctb_type: z.string(), + iri: z.string(), + label: z.string(), + }) + .array() + .meta({ id: 'AsctbTerms' }); + +export type TermsIndex = z.infer; + +export const TermsIndexSchema = z + .object({ + terms: z.string().array(), + purls: z.string().array(), + term_to_purls: z.number().array().array(), + purl_to_terms: z.number().array().array(), + }) + .meta({ id: 'TermsIndex' }); diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html index b07f052118..2108023d98 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html @@ -37,7 +37,11 @@ Search - + (); /** Column info */ readonly columns = input.required(); - /** ASCT+B term occurence data */ - readonly asctbTermOccurrences = input.required<[string, number][]>(); - /** Ontology tree data */ - readonly ontologyTree = input.required(); - /** Cell type tree data */ - readonly cellTypeTree = input.required(); - /** Biomarker tree data */ - readonly biomarkerTree = input.required(); - - /** All rows in the data */ - readonly allRows = signal([]); + // /** ASCT+B term occurence data */ + // readonly asctbTermOccurrences = input.required<[string, number][]>(); + // /** Ontology tree data */ + // readonly ontologyTree = input.required(); + // /** Cell type tree data */ + // readonly cellTypeTree = input.required(); + // /** Biomarker tree data */ + // readonly biomarkerTree = input.required(); + + readonly asctbTerms = input.required(); + readonly termsIndex = input.required(); + + // /** All rows in the data */ + // readonly allRows = signal([]); /** Filtered rows to display */ readonly filteredRows = signal([]); /** Whether or not the filter menu is closed */ @@ -152,13 +165,22 @@ export class MainPageComponent { const queryParams$ = inject(ActivatedRoute).queryParams; queryParams$.subscribe((queryParams) => this.setFiltersFomParams(queryParams)); + effect(() => { + // console.log(this.asctbTerms()); + // console.log(this.asctbTermOccurrences()); + }); + toObservable(this.data).subscribe((items) => { const objectData = this.resolveData(items['@graph']); - this.allRows.set(objectData); - this.filteredRows.set(this.allRows()); + this.filter.allRows.set(objectData); + this.filteredRows.set(this.filter.allRows()); this.setVersionCounts(items['@graph'] as DigitalObjectInfoWithHraVersions[]); }); + toObservable(this.termsIndex).subscribe((index) => { + this.filter.termsIndex.set(index); + }); + this.searchControl.valueChanges.subscribe((result?: string) => { this.onSearchChange(result === '' ? undefined : result); }); @@ -166,6 +188,7 @@ export class MainPageComponent { effect(() => { this.populateFilterOptions(); this.digitalObjectSearch().subscribe((results) => { + console.warn(results); this.applyMoreFilters(results); }); }); @@ -193,11 +216,11 @@ export class MainPageComponent { this.filters.set({ digitalObjects: dObjects, - releaseVersion: versions ? (Array.isArray(versions) ? versions : [versions]) : undefined, - organs: organs, - anatomicalStructures: as ? (Array.isArray(as) ? as : [as]) : undefined, - cellTypes: ct ? (Array.isArray(ct) ? ct : [ct]) : undefined, - biomarkers: b ? (Array.isArray(b) ? b : [b]) : undefined, + releaseVersion: this.handleValue(versions), + organs: this.handleValue(organs), + anatomicalStructures: this.handleValue(as), + cellTypes: this.handleValue(ct), + biomarkers: this.handleValue(b), searchTerm: search ?? '', }); this.searchControl.patchValue(this.filters().searchTerm); @@ -257,20 +280,21 @@ export class MainPageComponent { }); } - /** - * Calculates number of results for a filter option - * @param filterOption Name of filter option - * @param category - * @returns Number of results - */ - private calculateCount(filterOption: string, category: string): number { - return this.allRows().filter((row) => { - if (Array.isArray(row[category])) { - return row[category].some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); - } - return row[category] === filterOption; - }).length; - } + // /** + // * Calculates number of results for a filter option + // * @param filterOption Name of filter option + // * @param category + // * @returns Number of results + // */ + // private calculateCount(filterOption: string, category: string): number { + // return this.filter.allRows().filter((row) => { + // const cat = this.handleValue(row[category] as string[] | string | undefined); + // if (cat) { + // return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); + // } + // return cat === filterOption; + // }).length; + // } /** * Returns list of digital objects in the data as filter options @@ -282,7 +306,7 @@ export class MainPageComponent { return { id: filterOption, label: getProductLabel(filterOption), - count: this.calculateCount(filterOption, 'doType'), + count: this.filter.calculateCount(filterOption, 'doType'), tooltip: getProductTooltip(filterOption), }; }) @@ -311,41 +335,45 @@ export class MainPageComponent { * Returns list of organs in the data as filter options * @returns Filter options */ - private organsOptions(): FilterOption[] { + private generateOrganOptions(): FilterOption[] { return Array.from(this.kgFilterOptions().organOptions) .map((organOption) => { return { id: organOption, - label: sentenceCase(this.ontologyTree()?.nodes[organOption]?.label || ''), - count: this.calculateCount(organOption, 'organIds'), + label: sentenceCase(this.asctbTerms().find((term) => term.iri === organOption)?.label ?? organOption), + count: this.filter.calculateCount(organOption, 'organIds'), }; }) .sort((o1, o2) => o1.label.localeCompare(o2.label)); } - /** - * Returns ontology option data as filter options - * @param data Tree data - * @returns Filter options - */ - private ontologyOptions(data: OntologyTree): FilterOption[] { - return this.asctbTermOccurrences() - .filter((occurrence) => data.nodes[occurrence[0]]) - .map((occurrence) => { - return { - id: occurrence[0], - label: data.nodes[occurrence[0]].label || '', - count: occurrence[1], - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)); - } + // /** + // * Returns ontology option data as filter options + // * @param data Tree data + // * @returns Filter options + // */ + // private ontologyOptions(data: OntologyTree): FilterOption[] { + // return this.asctbTermOccurrences() + // .filter((occurrence) => data.nodes[occurrence[0]]) + // .map((occurrence) => { + // return { + // id: occurrence[0], + // label: data.nodes[occurrence[0]].label || '', + // count: occurrence[1], + // }; + // }) + // .sort((o1, o2) => o1.label.localeCompare(o2.label)); + // } /** * Populates filter categories with options */ private populateFilterOptions() { this.filterCategories.update((categories) => { + // console.log('digitalObjectsOptions', this.digitalObjectsOptions()); + // console.log('hraVersionsOptions', this.hraVersionsOptions()); + // console.log('generateOrganOptions', this.generateOrganOptions()); + // console.log('asctbTerms', this.asctbTerms()); return { digitalObjects: { ...categories['digitalObjects'], @@ -357,19 +385,19 @@ export class MainPageComponent { }, organs: { ...categories['organs'], - options: this.organsOptions(), + options: this.generateOrganOptions(), }, anatomicalStructures: { ...categories['anatomicalStructures'], - options: this.ontologyOptions(this.ontologyTree()), + options: this.filter.generateAsctbOptions('AS', this.asctbTerms()), }, cellTypes: { ...categories['cellTypes'], - options: this.ontologyOptions(this.cellTypeTree()), + options: this.filter.generateAsctbOptions('CT', this.asctbTerms()), }, biomarkers: { ...categories['biomarkers'], - options: this.ontologyOptions(this.biomarkerTree()), + options: this.filter.generateAsctbOptions('BM', this.asctbTerms()), }, }; }); @@ -379,7 +407,7 @@ export class MainPageComponent { * Applies additional filters to digital objects obtained from KG search and sets new filtered rows */ private applyMoreFilters(searchResults: string[]) { - let newFilteredRows = this.allRows(); + let newFilteredRows = this.filter.allRows(); newFilteredRows = newFilteredRows.filter((row) => searchResults.includes(row['purl'] as string)); if (this.filters().searchTerm && this.filters().searchTerm !== '') { @@ -431,7 +459,9 @@ export class MainPageComponent { return currentResults; } return currentResults.filter((row) => - ((row['organIds'] as string[]) ?? []).some((value) => currentOrganFilters?.includes(value)), + (this.handleValue(row['organIds'] as string[] | string | undefined) || []).some((value) => + currentOrganFilters?.includes(value), + ), ); } @@ -440,6 +470,7 @@ export class MainPageComponent { * @returns object search */ private digitalObjectSearch(): Observable { + console.log(this.filters()); const currentAnatomicalStructuresFilters = this.filters().anatomicalStructures; const currentCellTypesFilters = this.filters().cellTypes; const currentBiomarkerFilters = this.filters().biomarkers; @@ -453,16 +484,23 @@ export class MainPageComponent { }); } + private handleValue(value: string | string[] | undefined): string[] | undefined { + if (typeof value === 'string') { + return [value]; + } + return value; + } + /** * Returns unique filter options for digital objects, versions, and organs from KG API data */ private kgFilterOptions() { const objectFilterOptions = new Set(); const organFilterOptions = new Set(); - this.allRows().forEach((row) => { + this.filter.allRows().forEach((row) => { const type = row['doType']; objectFilterOptions.add(type as string); - const organs = row['organIds'] as string[]; + const organs = this.handleValue(row['organIds'] as string[] | string | undefined); if (organs) { for (const organ of organs) { organFilterOptions.add(organ); @@ -484,9 +522,10 @@ export class MainPageComponent { if (!data) { return []; } + // console.log(data); return data.map((item) => { - const organId = getOrganId(item); - const organLabel = this.ontologyTree()?.nodes[organId]?.label; + const organLabel = item.organs ? handleValue(item.organs)?.[0] : undefined; + // console.log(organLabel); return { id: item.lod, purl: item.purl, @@ -512,7 +551,7 @@ export class MainPageComponent { private attachDownloadOptions() { if (this.downloadId()) { this.http.get(this.downloadId() || '', { responseType: 'json' }).subscribe((data) => { - const match = this.allRows().find((row) => row['id'] === this.downloadId()); + const match = this.filter.allRows().find((row) => row['id'] === this.downloadId()); if (match) { match['downloadOptions'] = this.download.getDownloadOptions(data as DigitalObjectMetadata); } diff --git a/apps/kg-explorer/src/app/services/filter.service.ts b/apps/kg-explorer/src/app/services/filter.service.ts new file mode 100644 index 0000000000..c599968e8b --- /dev/null +++ b/apps/kg-explorer/src/app/services/filter.service.ts @@ -0,0 +1,36 @@ +import { Injectable, signal } from '@angular/core'; +import { AsctbTerms, TermsIndex } from '../digital-objects-metadata.schema'; +import { FilterOption, handleValue } from '../utils/utils'; +import { TableRow } from '@hra-ui/design-system/table'; + +@Injectable({ + providedIn: 'root', +}) +export class FilterService { + readonly allRows = signal([]); + readonly termsIndex = signal({ terms: [], purls: [], term_to_purls: [], purl_to_terms: [] }); + + generateAsctbOptions(type: string, objects: AsctbTerms): FilterOption[] { + return objects + .filter((term) => term.asctb_type === type) + .map((term) => { + return { + id: term.iri, + label: term.label, + count: this.termsIndex().term_to_purls[this.termsIndex().terms.indexOf(term.iri)]?.length ?? 0, + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)); + } + + calculateCount(filterOption: string, category: string): number { + console.log(this.allRows()); + return this.allRows().filter((row) => { + const cat = handleValue(row[category] as string[] | string | undefined); + if (cat) { + return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); + } + return cat === filterOption; + }).length; + } +} diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index c4b48f0b59..c6fc898f07 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -301,8 +301,9 @@ export const HRA_VERSION_DATA: Record = * @param item Digital object data item * @returns Organ id */ -export function getOrganId(item?: DigitalObjectInfo): string { - return item?.organIds && item.organIds.length === 1 ? item.organIds[0] : ''; +export function getOrganId(item?: DigitalObjectInfo): string | undefined { + const ids = handleValue(item?.organIds); + return ids && ids.length === 1 ? ids[0] : undefined; } /** @@ -311,7 +312,11 @@ export function getOrganId(item?: DigitalObjectInfo): string { * @returns Organ name in design system format */ export function getOrganIcon(item?: DigitalObjectInfo): string { - return `organ:${ORGAN_ICON_MAP[getOrganId(item)] ?? 'all-organs'}`; + if (getOrganId(item)) { + // console.warn(getOrganId(item)); + return `organ:${ORGAN_ICON_MAP[getOrganId(item) as string] ?? 'all-organs'}`; + } + return 'organ:all-organs'; } /** @@ -359,3 +364,10 @@ export function sentenceCase(value: string): string { const processedValue = value.trim().toLowerCase(); return processedValue.charAt(0).toUpperCase() + processedValue.slice(1); } + +export function handleValue(value: string | string[] | undefined): string[] | undefined { + if (typeof value === 'string') { + return [value]; + } + return value; +} From 4496f675b86883015bcf58035956d80f219420fb Mon Sep 17 00:00:00 2001 From: edlu77 Date: Fri, 17 Apr 2026 06:29:11 -0400 Subject: [PATCH 03/19] Refactor component for new filter service --- apps/kg-explorer/src/app/app.component.ts | 40 ++- apps/kg-explorer/src/app/app.routes.ts | 15 +- .../pages/main-page/main-page.component.ts | 230 ++---------------- .../metadata-page/metadata-page.component.ts | 52 ++-- .../src/app/services/filter.service.ts | 166 ++++++++++++- apps/kg-explorer/src/app/utils/kg-resolver.ts | 45 ---- apps/kg-explorer/src/app/utils/utils.ts | 1 - 7 files changed, 230 insertions(+), 319 deletions(-) diff --git a/apps/kg-explorer/src/app/app.component.ts b/apps/kg-explorer/src/app/app.component.ts index 98e2443424..0206293ac1 100644 --- a/apps/kg-explorer/src/app/app.component.ts +++ b/apps/kg-explorer/src/app/app.component.ts @@ -1,24 +1,16 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - ElementRef, - inject, - Signal, - signal, -} from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { HttpClient } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDividerModule } from '@angular/material/divider'; import { MatMenuModule } from '@angular/material/menu'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { DigitalObjectsJsonLd, HraKgService } from '@hra-api/ng-client'; import { BaseApplicationComponent } from '@hra-ui/application'; import { HraCommonModule } from '@hra-ui/common'; import { ButtonsModule } from '@hra-ui/design-system/buttons'; import { NavigationModule } from '@hra-ui/design-system/navigation'; import { MarkdownModule } from 'ngx-markdown'; import { HelpMenuOptions } from './app.routes'; +import { DigitalObjectsJsonLd } from './digital-objects-metadata.schema'; import { setMirrorUrl, setRemoteApiEndpoint } from './utils/endpoints'; import { isNavigating } from './utils/navigation'; import { routeData } from './utils/route-data'; @@ -76,8 +68,7 @@ export class AppComponent extends BaseApplicationComponent { /** Activated route service */ private readonly route = inject(ActivatedRoute); - /** HRA KG API service */ - private readonly kg = inject(HraKgService); + private readonly http = inject(HttpClient); /** Page title to display on the breadcrumbs */ private readonly pageTitle = signal(''); @@ -119,7 +110,7 @@ export class AppComponent extends BaseApplicationComponent { readonly objectId = computed(() => ['https://lod.humanatlas.io'].concat(this.params()).join('/')); /** Digital objects */ - private readonly digitalObjects: Signal; + private readonly digitalObjects = signal({ '@context': {}, '@graph': [] }); /** * Gets the page title for breadcrumbs @@ -149,13 +140,6 @@ export class AppComponent extends BaseApplicationComponent { } }); - effect(() => { - const id = this.objectId(); - const objects = this.digitalObjects(); - const match = objects['@graph']?.find((object) => object['@id'] === id); - this.pageTitle.set(match?.title || ''); - }); - this.router.events.pipe(takeUntilDestroyed()).subscribe(() => { const type = this.route.snapshot.root.firstChild?.params['type']; const name = this.route.snapshot.root.firstChild?.params['name']; @@ -164,6 +148,8 @@ export class AppComponent extends BaseApplicationComponent { } else { this.params.set([]); } + + this.setPageTitle(); }); const el = inject(ElementRef).nativeElement as HTMLElement; @@ -175,7 +161,15 @@ export class AppComponent extends BaseApplicationComponent { if (mirrorUrl) { setMirrorUrl(mirrorUrl); } + } - this.digitalObjects = toSignal(this.kg.digitalObjects(), { initialValue: {} }); + private setPageTitle() { + this.http.get('https://cdn.humanatlas.io/digital-objects/kg/digital-objects.jsonld').subscribe((data) => { + this.digitalObjects.set(data as DigitalObjectsJsonLd); + const id = this.objectId(); + const objects = this.digitalObjects(); + const match = objects['@graph']?.find((object) => object['@id'] === id); + this.pageTitle.set(match?.title || ''); + }); } } diff --git a/apps/kg-explorer/src/app/app.routes.ts b/apps/kg-explorer/src/app/app.routes.ts index 747df46f70..495b871810 100644 --- a/apps/kg-explorer/src/app/app.routes.ts +++ b/apps/kg-explorer/src/app/app.routes.ts @@ -7,15 +7,7 @@ import { createJsonSpecResolver } from '@hra-ui/design-system/content-templates/ import { AsctbTermsSchema, DigitalObjectsJsonLdSchema, TermsIndexSchema } from './digital-objects-metadata.schema'; import { MainPageComponent } from './pages/main-page/main-page.component'; import { MetadataPageComponent } from './pages/metadata-page/metadata-page.component'; -import { - asctbResolver, - biomarkersResolver, - cellTypeResolver, - documentationUrlResolver, - doMetadataResolver, - ontologyResolver, - productLabelResolver, -} from './utils/kg-resolver'; +import { documentationUrlResolver, doMetadataResolver, productLabelResolver } from './utils/kg-resolver'; /** Column info for digital object table */ export const DO_COLUMNS: TableColumn[] = [ @@ -119,10 +111,6 @@ export const appRoutes: Route[] = [ data: createJsonSpecResolver(DO_URL, DigitalObjectsJsonLdSchema), asctbTerms: createJsonSpecResolver(ASCTB_TERMS_URL, AsctbTermsSchema), termsIndex: createJsonSpecResolver(KG_TERMS_INDEX_URL, TermsIndexSchema), - // asctbTermOccurrences: asctbResolver(), - // ontologyTree: ontologyResolver(), - // cellTypeTree: cellTypeResolver(), - // biomarkerTree: biomarkersResolver(), }, }, { @@ -133,6 +121,7 @@ export const appRoutes: Route[] = [ }, resolve: { doData: createJsonSpecResolver(DO_URL, DigitalObjectsJsonLdSchema), + asctbTerms: createJsonSpecResolver(ASCTB_TERMS_URL, AsctbTermsSchema), metadata: doMetadataResolver(), documentationUrl: documentationUrlResolver(), typeLabel: productLabelResolver(), diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index e367154103..ca2e4c1464 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -6,7 +6,6 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSidenavModule } from '@angular/material/sidenav'; import { ActivatedRoute, Params, Router } from '@angular/router'; -import { HraKgService, OntologyTree } from '@hra-api/ng-client'; import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; import { HraCommonModule } from '@hra-ui/common'; import { BrandModule } from '@hra-ui/design-system/brand'; @@ -19,33 +18,23 @@ import { fromEvent, Observable } from 'rxjs'; import { FilterFormValues, FilterMenuComponent } from '../../components/filter-menu/filter-menu.component'; import { + AsctbTerms, DigitalObjectInfo, - DigitalObjectsJsonLd, DigitalObjectMetadata, - AsctbTerms, + DigitalObjectsJsonLd, TermsIndex, } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; +import { DigitalObjectInfoWithHraVersions, FilterService } from '../../services/filter.service'; import { FILTER_CATEGORY_INFO, - FilterOption, FilterOptionCategory, getOrganIcon, - getOrganId, getProductIcon, getProductLabel, - getProductTooltip, handleValue, - HRA_VERSION_DATA, sentenceCase, } from '../../utils/utils'; -import { FilterService } from '../../services/filter.service'; - -/** Digital object info interface with hraVersions */ -interface DigitalObjectInfoWithHraVersions extends DigitalObjectInfo { - /** List of HRA versions for the object */ - hraVersions: string[]; -} /** Current filter interface (each category contains string of filter option IDs) */ export interface CurrentFilters { @@ -99,8 +88,6 @@ const SCROLLBAR_TOP_OFFSET = '86'; export class MainPageComponent { /** Http service */ private readonly http = inject(HttpClient); - /** HRA KG API service */ - private readonly kg = inject(HraKgService); /** File download service */ readonly download = inject(DownloadService); @@ -120,20 +107,10 @@ export class MainPageComponent { readonly data = input.required(); /** Column info */ readonly columns = input.required(); - // /** ASCT+B term occurence data */ - // readonly asctbTermOccurrences = input.required<[string, number][]>(); - // /** Ontology tree data */ - // readonly ontologyTree = input.required(); - // /** Cell type tree data */ - // readonly cellTypeTree = input.required(); - // /** Biomarker tree data */ - // readonly biomarkerTree = input.required(); readonly asctbTerms = input.required(); readonly termsIndex = input.required(); - // /** All rows in the data */ - // readonly allRows = signal([]); /** Filtered rows to display */ readonly filteredRows = signal([]); /** Whether or not the filter menu is closed */ @@ -144,8 +121,6 @@ export class MainPageComponent { readonly filters = signal({}); /** Scroll viewport height for the digital object table */ readonly scrollHeight = signal(0); - /** Records HRA version counts for the version filter */ - readonly versionCounts = signal>({}); /** Id of digital object to download */ readonly downloadId = signal(undefined); @@ -165,19 +140,16 @@ export class MainPageComponent { const queryParams$ = inject(ActivatedRoute).queryParams; queryParams$.subscribe((queryParams) => this.setFiltersFomParams(queryParams)); - effect(() => { - // console.log(this.asctbTerms()); - // console.log(this.asctbTermOccurrences()); - }); - toObservable(this.data).subscribe((items) => { const objectData = this.resolveData(items['@graph']); this.filter.allRows.set(objectData); this.filteredRows.set(this.filter.allRows()); - this.setVersionCounts(items['@graph'] as DigitalObjectInfoWithHraVersions[]); + this.filter.setVersionCounts(items['@graph'] as DigitalObjectInfoWithHraVersions[]); }); toObservable(this.termsIndex).subscribe((index) => { + this.filter.data.set(this.data()); + this.filter.asctbTerms.set(this.asctbTerms()); this.filter.termsIndex.set(index); }); @@ -188,7 +160,6 @@ export class MainPageComponent { effect(() => { this.populateFilterOptions(); this.digitalObjectSearch().subscribe((results) => { - console.warn(results); this.applyMoreFilters(results); }); }); @@ -216,34 +187,16 @@ export class MainPageComponent { this.filters.set({ digitalObjects: dObjects, - releaseVersion: this.handleValue(versions), - organs: this.handleValue(organs), - anatomicalStructures: this.handleValue(as), - cellTypes: this.handleValue(ct), - biomarkers: this.handleValue(b), + releaseVersion: handleValue(versions), + organs: handleValue(organs), + anatomicalStructures: handleValue(as), + cellTypes: handleValue(ct), + biomarkers: handleValue(b), searchTerm: search ?? '', }); this.searchControl.patchValue(this.filters().searchTerm); } - /** - * Sets the version filter counts from the data - * @param data Digital object data - */ - private setVersionCounts(data: DigitalObjectInfoWithHraVersions[]) { - const result: Record = {}; - const allVersions = data.map((object) => object.hraVersions); - const flatVersions = allVersions.flat(); - for (const version of flatVersions) { - if (result[version]) { - result[version] += 1; - } else { - result[version] = 1; - } - } - this.versionCounts.set(result); - } - /** * Updates current filter selections when changed * @param formControls @@ -280,112 +233,23 @@ export class MainPageComponent { }); } - // /** - // * Calculates number of results for a filter option - // * @param filterOption Name of filter option - // * @param category - // * @returns Number of results - // */ - // private calculateCount(filterOption: string, category: string): number { - // return this.filter.allRows().filter((row) => { - // const cat = this.handleValue(row[category] as string[] | string | undefined); - // if (cat) { - // return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); - // } - // return cat === filterOption; - // }).length; - // } - - /** - * Returns list of digital objects in the data as filter options - * @returns Filter options - */ - private digitalObjectsOptions(): FilterOption[] { - return Array.from(this.kgFilterOptions().doOptions) - .map((filterOption) => { - return { - id: filterOption, - label: getProductLabel(filterOption), - count: this.filter.calculateCount(filterOption, 'doType'), - tooltip: getProductTooltip(filterOption), - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)); - } - - /** - * Returns HRA version data as filter options - * @returns Filter options - */ - private hraVersionsOptions(): FilterOption[] { - return Object.keys(HRA_VERSION_DATA) - .map((filterOption) => { - const versionData = HRA_VERSION_DATA[filterOption]; - return { - id: filterOption, - label: versionData ? versionData.label : filterOption, - count: this.versionCounts()[filterOption], - secondaryLabel: versionData ? versionData.date : undefined, - }; - }) - .sort((o1, o2) => o2.id.localeCompare(o1.id)); //Reverse order - } - - /** - * Returns list of organs in the data as filter options - * @returns Filter options - */ - private generateOrganOptions(): FilterOption[] { - return Array.from(this.kgFilterOptions().organOptions) - .map((organOption) => { - return { - id: organOption, - label: sentenceCase(this.asctbTerms().find((term) => term.iri === organOption)?.label ?? organOption), - count: this.filter.calculateCount(organOption, 'organIds'), - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)); - } - - // /** - // * Returns ontology option data as filter options - // * @param data Tree data - // * @returns Filter options - // */ - // private ontologyOptions(data: OntologyTree): FilterOption[] { - // return this.asctbTermOccurrences() - // .filter((occurrence) => data.nodes[occurrence[0]]) - // .map((occurrence) => { - // return { - // id: occurrence[0], - // label: data.nodes[occurrence[0]].label || '', - // count: occurrence[1], - // }; - // }) - // .sort((o1, o2) => o1.label.localeCompare(o2.label)); - // } - /** * Populates filter categories with options */ private populateFilterOptions() { this.filterCategories.update((categories) => { - // console.log('digitalObjectsOptions', this.digitalObjectsOptions()); - // console.log('hraVersionsOptions', this.hraVersionsOptions()); - // console.log('generateOrganOptions', this.generateOrganOptions()); - // console.log('asctbTerms', this.asctbTerms()); return { digitalObjects: { ...categories['digitalObjects'], - options: this.digitalObjectsOptions(), + options: this.filter.digitalObjectsOptions(), }, releaseVersions: { ...categories['releaseVersions'], - options: this.hraVersionsOptions(), + options: this.filter.hraVersionsOptions(), }, organs: { ...categories['organs'], - options: this.generateOrganOptions(), + options: this.filter.generateOrganOptions(), }, anatomicalStructures: { ...categories['anatomicalStructures'], @@ -417,10 +281,6 @@ export class MainPageComponent { if (this.filters().digitalObjects) { newFilteredRows = this.filterDigitalObjectResults(newFilteredRows); } - - if (this.filters().organs) { - newFilteredRows = this.filterOrganResults(newFilteredRows); - } this.filteredRows.set(newFilteredRows); } @@ -448,69 +308,24 @@ export class MainPageComponent { return currentResults.filter((row) => currentDigitalObjectsFilters?.includes(row['doType'] as string)); } - /** - * Filters an array of results by selected organ filters - * @param currentResults Results to filter - * @returns Filtered results - */ - private filterOrganResults(currentResults: TableRow[]): TableRow[] { - const currentOrganFilters = this.filters().organs; - if (currentOrganFilters && currentOrganFilters.length === 0) { - return currentResults; - } - return currentResults.filter((row) => - (this.handleValue(row['organIds'] as string[] | string | undefined) || []).some((value) => - currentOrganFilters?.includes(value), - ), - ); - } - /** * Performs KG DO search for selected ontology, cell type, biomarker, and HRA release version filters * @returns object search */ private digitalObjectSearch(): Observable { - console.log(this.filters()); + const currentOrganFilters = this.filters().organs; const currentAnatomicalStructuresFilters = this.filters().anatomicalStructures; const currentCellTypesFilters = this.filters().cellTypes; const currentBiomarkerFilters = this.filters().biomarkers; const currentHraVersionFilters = this.filters().releaseVersion; - return this.kg.doSearch({ - ontologyTerms: currentAnatomicalStructuresFilters, - cellTypeTerms: currentCellTypesFilters, - biomarkerTerms: currentBiomarkerFilters, - hraVersions: currentHraVersionFilters, - }); - } - - private handleValue(value: string | string[] | undefined): string[] | undefined { - if (typeof value === 'string') { - return [value]; - } - return value; - } - - /** - * Returns unique filter options for digital objects, versions, and organs from KG API data - */ - private kgFilterOptions() { - const objectFilterOptions = new Set(); - const organFilterOptions = new Set(); - this.filter.allRows().forEach((row) => { - const type = row['doType']; - objectFilterOptions.add(type as string); - const organs = this.handleValue(row['organIds'] as string[] | string | undefined); - if (organs) { - for (const organ of organs) { - organFilterOptions.add(organ); - } - } + return this.filter.doSearch({ + organs: currentOrganFilters || [], + versions: currentHraVersionFilters || [], + ontologyTerms: currentAnatomicalStructuresFilters || [], + cellTypeTerms: currentCellTypesFilters || [], + biomarkerTerms: currentBiomarkerFilters || [], }); - return { - doOptions: objectFilterOptions, - organOptions: organFilterOptions, - }; } /** @@ -522,14 +337,13 @@ export class MainPageComponent { if (!data) { return []; } - // console.log(data); return data.map((item) => { const organLabel = item.organs ? handleValue(item.organs)?.[0] : undefined; - // console.log(organLabel); return { id: item.lod, purl: item.purl, doType: item.doType, + hraVersions: item.hraVersions, doVersion: item.doVersion, organIds: item.organIds, title: item.title, diff --git a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts index b64da2774f..cb26e6508c 100644 --- a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts +++ b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts @@ -4,7 +4,6 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, effect, inject, input, signal } from import { toObservable } from '@angular/core/rxjs-interop'; import { MatChipsModule } from '@angular/material/chips'; import { ActivatedRoute, Router } from '@angular/router'; -import { V1Service } from '@hra-api/ng-client'; import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; import { HraCommonModule } from '@hra-ui/common'; import { PageSectionComponent } from '@hra-ui/design-system/content-templates/page-section'; @@ -15,9 +14,14 @@ import { MarkdownComponent } from 'ngx-markdown'; import { MetadataLayoutModule } from '../../components/metadata-layout/metadata-layout.module'; import { ProvenanceMenuComponent } from '../../components/provenance-menu/provenance-menu.component'; -import { DigitalObjectsJsonLd, DigitalObjectMetadata, PersonInfo } from '../../digital-objects-metadata.schema'; +import { + AsctbTerms, + DigitalObjectMetadata, + DigitalObjectsJsonLd, + PersonInfo, +} from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; -import { getOrganIcon, getProductIcon, getProductLabel, sentenceCase } from '../../utils/utils'; +import { getOrganIcon, getProductIcon, getProductLabel, handleValue, sentenceCase } from '../../utils/utils'; /** * Metadata page for a digital object @@ -45,11 +49,11 @@ export class MetadataPageComponent { private readonly route = inject(ActivatedRoute); /** File download service */ private readonly download = inject(DownloadService); - /** HRA V1 API service */ - private readonly v1 = inject(V1Service); /** Raw digital object data from API */ readonly doData = input.required(); + readonly asctbTerms = input.required(); + /** Column data for metadata table */ readonly columns = input.required(); /** Metadata for the digital object */ @@ -140,29 +144,25 @@ export class MetadataPageComponent { } this.icons.set(icons); - this.v1.ontologyTreeModel({}).subscribe((ontologyData) => { - if (pageItem) { - if (Array.isArray(pageItem.versions)) { - this.availableVersions.set(pageItem.versions); - } else { - this.availableVersions.set([pageItem.versions]); - } - const tags = [{ id: type, label: getProductLabel(type), type: 'do' }]; - if (pageItem.organIds) { - const ids = Array.isArray(pageItem.organIds) ? pageItem.organIds : [pageItem.organIds]; - for (const organId of ids) { - if (ontologyData.nodes[organId]) { - tags.push({ - id: organId, - label: sentenceCase(ontologyData.nodes[organId].label || ''), - type: 'organs', - }); - } - } + if (pageItem) { + if (Array.isArray(pageItem.versions)) { + this.availableVersions.set(pageItem.versions); + } else { + this.availableVersions.set([pageItem.versions]); + } + const tags = [{ id: type, label: getProductLabel(type), type: 'do' }]; + if (pageItem.organIds) { + const ids = handleValue(pageItem.organIds) || []; + for (const organId of ids) { + tags.push({ + id: organId, + label: sentenceCase(this.asctbTerms().find((term) => term.iri === organId)?.label || ''), + type: 'organs', + }); } - this.tags.set(tags); } - }); + this.tags.set(tags); + } } }); } diff --git a/apps/kg-explorer/src/app/services/filter.service.ts b/apps/kg-explorer/src/app/services/filter.service.ts index c599968e8b..fb102eff9e 100644 --- a/apps/kg-explorer/src/app/services/filter.service.ts +++ b/apps/kg-explorer/src/app/services/filter.service.ts @@ -1,14 +1,123 @@ import { Injectable, signal } from '@angular/core'; -import { AsctbTerms, TermsIndex } from '../digital-objects-metadata.schema'; -import { FilterOption, handleValue } from '../utils/utils'; import { TableRow } from '@hra-ui/design-system/table'; +import { Observable } from 'rxjs'; +import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; +import { + FilterOption, + getProductLabel, + getProductTooltip, + handleValue, + HRA_VERSION_DATA, + sentenceCase, +} from '../utils/utils'; + +/** Digital object info interface with hraVersions */ +export interface DigitalObjectInfoWithHraVersions extends DigitalObjectInfo { + /** List of HRA versions for the object */ + hraVersions: string[]; +} @Injectable({ providedIn: 'root', }) export class FilterService { + readonly data = signal({ '@context': {}, '@graph': [] }); readonly allRows = signal([]); + readonly asctbTerms = signal([]); readonly termsIndex = signal({ terms: [], purls: [], term_to_purls: [], purl_to_terms: [] }); + /** Records HRA version counts for the version filter */ + readonly versionCounts = signal>({}); + + /** + * Sets the version filter counts from the data + * @param data Digital object data + */ + setVersionCounts(data: DigitalObjectInfoWithHraVersions[]) { + const result: Record = {}; + const allVersions = data.map((object) => object.hraVersions); + const flatVersions = allVersions.flat(); + for (const version of flatVersions) { + if (result[version]) { + result[version] += 1; + } else { + result[version] = 1; + } + } + this.versionCounts.set(result); + } + + /** + * Returns unique filter options for digital objects, versions, and organs from KG API data + */ + kgFilterOptions() { + const objectFilterOptions = new Set(); + const organFilterOptions = new Set(); + this.allRows().forEach((row) => { + const type = row['doType']; + objectFilterOptions.add(type as string); + const organs = handleValue(row['organIds'] as string[] | string | undefined); + if (organs) { + for (const organ of organs) { + organFilterOptions.add(organ); + } + } + }); + return { + doOptions: objectFilterOptions, + organOptions: organFilterOptions, + }; + } + + /** + * Returns list of digital objects in the data as filter options + * @returns Filter options + */ + digitalObjectsOptions(): FilterOption[] { + return Array.from(this.kgFilterOptions().doOptions) + .map((filterOption) => { + return { + id: filterOption, + label: getProductLabel(filterOption), + count: this.calculateCount(filterOption, 'doType'), + tooltip: getProductTooltip(filterOption), + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)); + } + + /** + * Returns HRA version data as filter options + * @returns Filter options + */ + hraVersionsOptions(): FilterOption[] { + return Object.keys(HRA_VERSION_DATA) + .map((filterOption) => { + const versionData = HRA_VERSION_DATA[filterOption]; + return { + id: filterOption, + label: versionData ? versionData.label : filterOption, + count: this.versionCounts()[filterOption], + secondaryLabel: versionData ? versionData.date : undefined, + }; + }) + .sort((o1, o2) => o2.id.localeCompare(o1.id)); //Reverse order + } + + /** + * Returns list of organs in the data as filter options + * @returns Filter options + */ + generateOrganOptions(): FilterOption[] { + return Array.from(this.kgFilterOptions().organOptions) + .map((organOption) => { + return { + id: organOption, + label: sentenceCase(this.asctbTerms().find((term) => term.iri === organOption)?.label ?? organOption), + count: this.calculateCount(organOption, 'organIds'), + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)); + } generateAsctbOptions(type: string, objects: AsctbTerms): FilterOption[] { return objects @@ -24,7 +133,6 @@ export class FilterService { } calculateCount(filterOption: string, category: string): number { - console.log(this.allRows()); return this.allRows().filter((row) => { const cat = handleValue(row[category] as string[] | string | undefined); if (cat) { @@ -33,4 +141,56 @@ export class FilterService { return cat === filterOption; }).length; } + + doSearch(options: { + organs: string[]; + versions: string[]; + ontologyTerms: string[]; + cellTypeTerms: string[]; + biomarkerTerms: string[]; + }): Observable { + const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms } = options; + return new Observable((subscriber) => { + if ( + organs.length === 0 && + versions.length === 0 && + ontologyTerms.length === 0 && + cellTypeTerms.length === 0 && + biomarkerTerms.length === 0 + ) { + subscriber.next(this.allRows().map((row) => row['purl'] as string)); + subscriber.complete(); + } else { + const filteredByVersions = this.allRows().filter((row) => { + const rowVersions = handleValue(row['hraVersions'] as string[] | string | undefined); + if (rowVersions) { + return rowVersions?.some((version) => versions.includes(version)); + } + return false; + }); + const versionsSet = new Set(filteredByVersions.map((row) => row['purl'] as string)); + const filteredPurls = new Set([ + ...versionsSet, + ...this.getPurlsFromTerms(organs), + ...this.getPurlsFromTerms(ontologyTerms), + ...this.getPurlsFromTerms(cellTypeTerms), + ...this.getPurlsFromTerms(biomarkerTerms), + ]); + const filteredPurlsArray = Array.from(filteredPurls); + subscriber.next(filteredPurlsArray); + subscriber.complete(); + } + }); + } + + getPurlsFromTerms(terms: string[]): Set { + const purls = new Set(); + terms.forEach((term) => { + const purlIndexes = this.termsIndex().term_to_purls[this.termsIndex().terms.indexOf(term)]; + if (purlIndexes) { + purlIndexes.forEach((purlIndex) => purls.add(this.termsIndex().purls[purlIndex])); + } + }); + return purls; + } } diff --git a/apps/kg-explorer/src/app/utils/kg-resolver.ts b/apps/kg-explorer/src/app/utils/kg-resolver.ts index c5e8e44348..4a26574b7e 100644 --- a/apps/kg-explorer/src/app/utils/kg-resolver.ts +++ b/apps/kg-explorer/src/app/utils/kg-resolver.ts @@ -1,7 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; -import { HraKgService, OntologyTree, V1Service } from '@hra-api/ng-client'; import { catchError, map, of } from 'rxjs'; import { DigitalObjectMetadata } from '../digital-objects-metadata.schema'; import { injectMirrorUrl } from './endpoints'; @@ -25,50 +24,6 @@ export function doMetadataResolver(): ResolveFn { }; } -/** - * Creates a resolver for ASCTB term counts - * @returns resolver - */ -export function asctbResolver(): ResolveFn<[string, number][]> { - return () => { - const kg = inject(HraKgService); - return kg.asctbTermOccurences({}).pipe(map((data) => Object.entries(data))); - }; -} - -/** - * Creates a resolver for ontology tree - * @returns resolver - */ -export function ontologyResolver(): ResolveFn { - return () => { - const v1 = inject(V1Service); - return v1.ontologyTreeModel({}); - }; -} - -/** - * Creates a resolver for cell type ontology tree - * @returns resolver - */ -export function cellTypeResolver(): ResolveFn { - return () => { - const v1 = inject(V1Service); - return v1.cellTypeTreeModel({}); - }; -} - -/** - * Creates a resolver for biomarkers ontology tree - * @returns resolver - */ -export function biomarkersResolver(): ResolveFn { - return () => { - const v1 = inject(V1Service); - return v1.biomarkerTreeModel({}); - }; -} - /** * Creates documentation url resolver from the route * @returns url resolver diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index c6fc898f07..385bbf7fe5 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -313,7 +313,6 @@ export function getOrganId(item?: DigitalObjectInfo): string | undefined { */ export function getOrganIcon(item?: DigitalObjectInfo): string { if (getOrganId(item)) { - // console.warn(getOrganId(item)); return `organ:${ORGAN_ICON_MAP[getOrganId(item) as string] ?? 'all-organs'}`; } return 'organ:all-organs'; From a8260bb4bbc5c17792f42c1c1e3faffba0a3e841 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Fri, 17 Apr 2026 17:57:19 -0400 Subject: [PATCH 04/19] Add filter state --- .../filter-menu/filter-menu.component.ts | 2 +- .../pages/main-page/main-page.component.html | 2 +- .../pages/main-page/main-page.component.ts | 130 +++++------------- .../src/app/services/filter.service.ts | 97 ++++++++----- .../src/app/state/filters.store.ts | 4 + .../src/app/state/with-filters.feature.ts | 74 ++++++++++ 6 files changed, 177 insertions(+), 132 deletions(-) create mode 100644 apps/kg-explorer/src/app/state/filters.store.ts create mode 100644 apps/kg-explorer/src/app/state/with-filters.feature.ts diff --git a/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts b/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts index d76147fdc8..3e76e8a891 100644 --- a/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts +++ b/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts @@ -8,7 +8,7 @@ import { IconsModule } from '@hra-ui/design-system/icons'; import { ScrollingModule } from '@hra-ui/design-system/scrolling'; import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip'; -import { CurrentFilters } from '../../pages/main-page/main-page.component'; +import { CurrentFilters } from '../../state/with-filters.feature'; import { FilterOption, FilterOptionCategory } from '../../utils/utils'; import { FilterMenuOverlayComponent } from './filter-menu-overlay/filter-menu-overlay.component'; diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html index 2108023d98..e9d90480d9 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html @@ -24,7 +24,7 @@ hraFeature="filter-menu" [filterCategories]="filterCategoriesArray()" [formClosed]="!sidenav.opened" - [currentFilters]="filters()" + [currentFilters]="store.filters()" (toggleForm)="sidenav.toggle()" (formChanges)="handleFilterSelectionChanges($event); table.scrollToTop()" /> diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index ca2e4c1464..138983ab37 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -26,6 +26,7 @@ import { } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; import { DigitalObjectInfoWithHraVersions, FilterService } from '../../services/filter.service'; +import { FiltersStore } from '../../state/filters.store'; import { FILTER_CATEGORY_INFO, FilterOptionCategory, @@ -36,24 +37,6 @@ import { sentenceCase, } from '../../utils/utils'; -/** Current filter interface (each category contains string of filter option IDs) */ -export interface CurrentFilters { - /** Digital object filters */ - digitalObjects?: string[]; - /** Release version filters */ - releaseVersion?: string[]; - /** Organ filters */ - organs?: string[]; - /** Anatomical structures filters */ - anatomicalStructures?: string[]; - /** Cell type filters */ - cellTypes?: string[]; - /** Biomarker filters */ - biomarkers?: string[]; - /** Search term filters */ - searchTerm?: string; -} - /** Amount in pixels to move scrollbar downwards so it doesn't start at the header */ const SCROLLBAR_TOP_OFFSET = '86'; @@ -78,6 +61,7 @@ const SCROLLBAR_TOP_OFFSET = '86'; ], templateUrl: './main-page.component.html', styleUrl: './main-page.component.scss', + providers: [FiltersStore], changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class.filter-closed]': 'filterClosed()', @@ -91,7 +75,6 @@ export class MainPageComponent { /** File download service */ readonly download = inject(DownloadService); - readonly filter = inject(FilterService); /** Router service */ readonly router = inject(Router); @@ -117,8 +100,10 @@ export class MainPageComponent { readonly filterClosed = signal(false); /** Filter categories */ readonly filterCategories = signal>(FILTER_CATEGORY_INFO); - /** Currently selected filters */ - readonly filters = signal({}); + + readonly filter = inject(FilterService); + readonly store = inject(FiltersStore); + /** Scroll viewport height for the digital object table */ readonly scrollHeight = signal(0); /** Id of digital object to download */ @@ -160,7 +145,8 @@ export class MainPageComponent { effect(() => { this.populateFilterOptions(); this.digitalObjectSearch().subscribe((results) => { - this.applyMoreFilters(results); + const newFilteredRows = this.filter.allRows().filter((row) => results.includes(row['purl'] as string)); + this.filteredRows.set(newFilteredRows); }); }); @@ -185,16 +171,16 @@ export class MainPageComponent { const b = queryParams['b']; const search = queryParams['search']; - this.filters.set({ + this.store.updateFilters({ digitalObjects: dObjects, - releaseVersion: handleValue(versions), - organs: handleValue(organs), - anatomicalStructures: handleValue(as), - cellTypes: handleValue(ct), - biomarkers: handleValue(b), + releaseVersion: handleValue(versions) || [], + organs: handleValue(organs) || [], + anatomicalStructures: handleValue(as) || [], + cellTypes: handleValue(ct) || [], + biomarkers: handleValue(b) || [], searchTerm: search ?? '', }); - this.searchControl.patchValue(this.filters().searchTerm); + this.searchControl.patchValue(this.store.searchTerm()); } /** @@ -202,17 +188,7 @@ export class MainPageComponent { * @param formControls */ handleFilterSelectionChanges(formValues: FilterFormValues) { - const updatedFilters: CurrentFilters = { - digitalObjects: formValues.digitalObjects?.map((obj) => obj.id) || undefined, - releaseVersion: formValues.releaseVersion?.map((obj) => obj.id) || undefined, - organs: formValues.organs?.map((obj) => obj.id) || undefined, - anatomicalStructures: formValues.anatomicalStructures?.map((obj) => obj.id) || undefined, - cellTypes: formValues.cellTypes?.map((obj) => obj.id) || undefined, - biomarkers: formValues.biomarkers?.map((obj) => obj.id) || undefined, - searchTerm: this.filters().searchTerm || undefined, - }; - - this.filters.set(updatedFilters); + this.store.updateFiltersFromForm(formValues); this.updateQueryParamsFromFilters(); } @@ -222,13 +198,13 @@ export class MainPageComponent { private updateQueryParamsFromFilters() { this.router.navigate([''], { queryParams: { - do: this.filters().digitalObjects, - versions: this.filters().releaseVersion, - organs: this.filters().organs, - as: this.filters().anatomicalStructures, - ct: this.filters().cellTypes, - b: this.filters().biomarkers, - search: this.filters().searchTerm, + do: this.store.digitalObjects(), + versions: this.store.releaseVersion(), + organs: this.store.organs(), + as: this.store.anatomicalStructures(), + ct: this.store.cellTypes(), + b: this.store.biomarkers(), + search: this.store.searchTerm(), }, }); } @@ -267,57 +243,18 @@ export class MainPageComponent { }); } - /** - * Applies additional filters to digital objects obtained from KG search and sets new filtered rows - */ - private applyMoreFilters(searchResults: string[]) { - let newFilteredRows = this.filter.allRows(); - newFilteredRows = newFilteredRows.filter((row) => searchResults.includes(row['purl'] as string)); - - if (this.filters().searchTerm && this.filters().searchTerm !== '') { - newFilteredRows = this.filterSearchFormResults(newFilteredRows); - } - - if (this.filters().digitalObjects) { - newFilteredRows = this.filterDigitalObjectResults(newFilteredRows); - } - this.filteredRows.set(newFilteredRows); - } - - /** - * Filters an array of results by the search form input - * @param currentResults Results to filter - * @returns Filtered results - */ - private filterSearchFormResults(currentResults: TableRow[]): TableRow[] { - return currentResults.filter((row) => { - return (row['title'] as string).toLowerCase().includes((this.filters().searchTerm ?? '').toLowerCase()); - }); - } - - /** - * Filters an array of results by selected digital object filters - * @param currentResults Results to filter - * @returns Filtered results - */ - private filterDigitalObjectResults(currentResults: TableRow[]): TableRow[] { - const currentDigitalObjectsFilters = this.filters().digitalObjects; - if (currentDigitalObjectsFilters && currentDigitalObjectsFilters.length === 0) { - return currentResults; - } - return currentResults.filter((row) => currentDigitalObjectsFilters?.includes(row['doType'] as string)); - } - /** * Performs KG DO search for selected ontology, cell type, biomarker, and HRA release version filters * @returns object search */ private digitalObjectSearch(): Observable { - const currentOrganFilters = this.filters().organs; - const currentAnatomicalStructuresFilters = this.filters().anatomicalStructures; - const currentCellTypesFilters = this.filters().cellTypes; - const currentBiomarkerFilters = this.filters().biomarkers; - const currentHraVersionFilters = this.filters().releaseVersion; + const currentOrganFilters = this.store.organs ? this.store.organs() : []; + const currentAnatomicalStructuresFilters = this.store.anatomicalStructures ? this.store.anatomicalStructures() : []; + const currentCellTypesFilters = this.store.cellTypes ? this.store.cellTypes() : []; + const currentBiomarkerFilters = this.store.biomarkers ? this.store.biomarkers() : []; + const currentHraVersionFilters = this.store.releaseVersion ? this.store.releaseVersion() : []; + const currentSearchTerm = this.store.searchTerm(); + const digitalObjects = this.store.digitalObjects() ? this.store.digitalObjects() : []; return this.filter.doSearch({ organs: currentOrganFilters || [], @@ -325,6 +262,8 @@ export class MainPageComponent { ontologyTerms: currentAnatomicalStructuresFilters || [], cellTypeTerms: currentCellTypesFilters || [], biomarkerTerms: currentBiomarkerFilters || [], + searchTerm: currentSearchTerm, + digitalObjects: digitalObjects || [], }); } @@ -378,10 +317,7 @@ export class MainPageComponent { * @param searchTerm Search input */ private onSearchChange(searchTerm?: string): void { - this.filters.set({ - ...this.filters(), - searchTerm, - }); + this.store.updateSearchTerm(searchTerm); this.updateQueryParamsFromFilters(); } diff --git a/apps/kg-explorer/src/app/services/filter.service.ts b/apps/kg-explorer/src/app/services/filter.service.ts index fb102eff9e..4925374b31 100644 --- a/apps/kg-explorer/src/app/services/filter.service.ts +++ b/apps/kg-explorer/src/app/services/filter.service.ts @@ -1,6 +1,6 @@ -import { Injectable, signal } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { TableRow } from '@hra-ui/design-system/table'; -import { Observable } from 'rxjs'; +import { from, Observable } from 'rxjs'; import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; import { FilterOption, @@ -10,6 +10,7 @@ import { HRA_VERSION_DATA, sentenceCase, } from '../utils/utils'; +import { FiltersStore } from '../state/filters.store'; /** Digital object info interface with hraVersions */ export interface DigitalObjectInfoWithHraVersions extends DigitalObjectInfo { @@ -28,6 +29,8 @@ export class FilterService { /** Records HRA version counts for the version filter */ readonly versionCounts = signal>({}); + readonly store = inject(FiltersStore); + /** * Sets the version filter counts from the data * @param data Digital object data @@ -148,41 +151,69 @@ export class FilterService { ontologyTerms: string[]; cellTypeTerms: string[]; biomarkerTerms: string[]; + searchTerm: string | undefined; + digitalObjects: string[]; }): Observable { - const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms } = options; - return new Observable((subscriber) => { - if ( - organs.length === 0 && - versions.length === 0 && - ontologyTerms.length === 0 && - cellTypeTerms.length === 0 && - biomarkerTerms.length === 0 - ) { - subscriber.next(this.allRows().map((row) => row['purl'] as string)); - subscriber.complete(); - } else { - const filteredByVersions = this.allRows().filter((row) => { - const rowVersions = handleValue(row['hraVersions'] as string[] | string | undefined); - if (rowVersions) { - return rowVersions?.some((version) => versions.includes(version)); - } - return false; - }); - const versionsSet = new Set(filteredByVersions.map((row) => row['purl'] as string)); - const filteredPurls = new Set([ - ...versionsSet, - ...this.getPurlsFromTerms(organs), - ...this.getPurlsFromTerms(ontologyTerms), - ...this.getPurlsFromTerms(cellTypeTerms), - ...this.getPurlsFromTerms(biomarkerTerms), - ]); - const filteredPurlsArray = Array.from(filteredPurls); - subscriber.next(filteredPurlsArray); - subscriber.complete(); - } + const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms, searchTerm, digitalObjects } = options; + let result = []; + if ( + organs.length === 0 && + versions.length === 0 && + ontologyTerms.length === 0 && + cellTypeTerms.length === 0 && + biomarkerTerms.length === 0 && + digitalObjects.length === 0 && + !searchTerm + ) { + result = this.allRows().map((row) => row['purl'] as string); + } else { + const filteredByDigitalObjects = this.allRows().filter((row) => { + const type = row['doType'] as string; + return digitalObjects.includes(type); + }); + const filteredByVersions = this.allRows().filter((row) => { + const rowVersions = handleValue(row['hraVersions'] as string[] | string | undefined); + if (rowVersions) { + return rowVersions?.some((version) => versions.includes(version)); + } + return false; + }); + const filteredBySearchTerm = this.allRows().filter((row) => { + const title = row['title'] as string; + return searchTerm ? title.toLowerCase().includes((searchTerm || '').toLowerCase()) : false; + }); + const doSet = new Set(filteredByDigitalObjects.map((row) => row['purl'] as string)); + const versionsSet = new Set(filteredByVersions.map((row) => row['purl'] as string)); + const searchResults = new Set(filteredBySearchTerm.map((row) => row['purl'] as string)); + + const filteredPurls = new Set([ + ...doSet, + ...versionsSet, + ...searchResults, + ...this.getPurlsFromTerms(organs), + ...this.getPurlsFromTerms(ontologyTerms), + ...this.getPurlsFromTerms(cellTypeTerms), + ...this.getPurlsFromTerms(biomarkerTerms), + ]); + result = Array.from(filteredPurls); + } + return from([result]); + } + + filterSearchFormResults(currentResults: string[], searchTerm: string | undefined): string[] { + return currentResults.filter((entry) => { + return entry.toLowerCase().includes((searchTerm || '').toLowerCase()); }); } + filterDigitalObjectResults(currentResults: string[]): string[] { + const currentDigitalObjectsFilters = this.store.digitalObjects(); + if (currentDigitalObjectsFilters && currentDigitalObjectsFilters.length === 0) { + return currentResults; + } + return currentResults.filter((row) => currentDigitalObjectsFilters?.includes(row as string)); + } + getPurlsFromTerms(terms: string[]): Set { const purls = new Set(); terms.forEach((term) => { diff --git a/apps/kg-explorer/src/app/state/filters.store.ts b/apps/kg-explorer/src/app/state/filters.store.ts new file mode 100644 index 0000000000..75e64c23f6 --- /dev/null +++ b/apps/kg-explorer/src/app/state/filters.store.ts @@ -0,0 +1,4 @@ +import { signalStore } from '@ngrx/signals'; +import { withFilters } from './with-filters.feature'; + +export const FiltersStore = signalStore({ providedIn: 'root' }, withFilters()); diff --git a/apps/kg-explorer/src/app/state/with-filters.feature.ts b/apps/kg-explorer/src/app/state/with-filters.feature.ts new file mode 100644 index 0000000000..97bd14d62b --- /dev/null +++ b/apps/kg-explorer/src/app/state/with-filters.feature.ts @@ -0,0 +1,74 @@ +import { computed } from '@angular/core'; +import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals'; +import { FilterFormValues } from '../components/filter-menu/filter-menu.component'; + +/** Current filter interface (each category contains string of filter option IDs) */ +export interface CurrentFilters { + /** Digital object filters */ + digitalObjects: string[]; + /** Release version filters */ + releaseVersion: string[]; + /** Organ filters */ + organs: string[]; + /** Anatomical structures filters */ + anatomicalStructures: string[]; + /** Cell type filters */ + cellTypes: string[]; + /** Biomarker filters */ + biomarkers: string[]; + /** Search term filters */ + searchTerm: string | undefined; +} + +/** Initial state for the filters store */ +const initialState: CurrentFilters = { + digitalObjects: [], + releaseVersion: [], + organs: [], + anatomicalStructures: [], + cellTypes: [], + biomarkers: [], + searchTerm: undefined, +}; + +export function withFilters() { + return signalStoreFeature( + withState(initialState), + withComputed((store) => { + return { + filters: computed(() => { + return { + digitalObjects: store.digitalObjects(), + releaseVersion: store.releaseVersion(), + organs: store.organs(), + anatomicalStructures: store.anatomicalStructures(), + cellTypes: store.cellTypes(), + biomarkers: store.biomarkers(), + searchTerm: store.searchTerm(), + }; + }), + }; + }), + withMethods((store) => ({ + updateFilters: signalMethod((filters: CurrentFilters) => { + patchState(store, filters); + }), + updateFiltersFromForm: signalMethod((formValues: FilterFormValues) => { + const updatedFilters: CurrentFilters = { + digitalObjects: formValues.digitalObjects?.map((obj) => obj.id) || [], + releaseVersion: formValues.releaseVersion?.map((obj) => obj.id) || [], + organs: formValues.organs?.map((obj) => obj.id) || [], + anatomicalStructures: formValues.anatomicalStructures?.map((obj) => obj.id) || [], + cellTypes: formValues.cellTypes?.map((obj) => obj.id) || [], + biomarkers: formValues.biomarkers?.map((obj) => obj.id) || [], + searchTerm: store.searchTerm(), + }; + patchState(store, updatedFilters); + }), + + updateSearchTerm: signalMethod((searchTerm?: string) => { + patchState(store, { searchTerm }); + }), + })), + ); +} From 3aef9836414abe32ad5a8e2ef24e87087d46eb3d Mon Sep 17 00:00:00 2001 From: edlu77 Date: Mon, 20 Apr 2026 17:50:50 -0400 Subject: [PATCH 05/19] Move more logic to filters state --- .../filter-menu/filter-menu.component.ts | 5 +- .../pages/main-page/main-page.component.html | 4 +- .../pages/main-page/main-page.component.ts | 223 +++++------------ .../src/app/services/filter.service.ts | 227 ----------------- .../src/app/services/search.service.ts | 88 +++++++ .../src/app/state/with-filters.feature.ts | 234 ++++++++++++++++-- apps/kg-explorer/src/app/utils/utils.ts | 34 ++- 7 files changed, 391 insertions(+), 424 deletions(-) delete mode 100644 apps/kg-explorer/src/app/services/filter.service.ts create mode 100644 apps/kg-explorer/src/app/services/search.service.ts diff --git a/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts b/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts index 3e76e8a891..9802479e03 100644 --- a/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts +++ b/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts @@ -9,7 +9,7 @@ import { ScrollingModule } from '@hra-ui/design-system/scrolling'; import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip'; import { CurrentFilters } from '../../state/with-filters.feature'; -import { FilterOption, FilterOptionCategory } from '../../utils/utils'; +import { FilterOption, FilterOptionCategory, FilterType } from '../../utils/utils'; import { FilterMenuOverlayComponent } from './filter-menu-overlay/filter-menu-overlay.component'; /** Filter form values */ @@ -28,9 +28,6 @@ export interface FilterFormValues { biomarkers: FilterOption[] | null; } -/** Filter types for the filter form */ -type FilterType = 'digitalObjects' | 'releaseVersion' | 'organs' | 'anatomicalStructures' | 'cellTypes' | 'biomarkers'; - /** * Filter menu for the KG Explorer */ diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html index e9d90480d9..e3b6dce9f3 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html @@ -22,7 +22,7 @@ > (false); /** Filter categories */ - readonly filterCategories = signal>(FILTER_CATEGORY_INFO); - - readonly filter = inject(FilterService); - readonly store = inject(FiltersStore); - + readonly filterCategories = signal([]); /** Scroll viewport height for the digital object table */ readonly scrollHeight = signal(0); /** Id of digital object to download */ readonly downloadId = signal(undefined); - /** Filter categories as an array */ - readonly filterCategoriesArray = computed(() => Object.values(this.filterCategories())); - /** * Sets the initial filters according to query params * Sets filtered rows to all rows on init @@ -123,35 +109,27 @@ export class MainPageComponent { */ constructor() { const queryParams$ = inject(ActivatedRoute).queryParams; - queryParams$.subscribe((queryParams) => this.setFiltersFomParams(queryParams)); - - toObservable(this.data).subscribe((items) => { - const objectData = this.resolveData(items['@graph']); - this.filter.allRows.set(objectData); - this.filteredRows.set(this.filter.allRows()); - this.filter.setVersionCounts(items['@graph'] as DigitalObjectInfoWithHraVersions[]); - }); - - toObservable(this.termsIndex).subscribe((index) => { - this.filter.data.set(this.data()); - this.filter.asctbTerms.set(this.asctbTerms()); - this.filter.termsIndex.set(index); - }); + queryParams$.subscribe((queryParams) => this.setFiltersFromQueryParams(queryParams)); + this.store.setData(this.data); + this.store.setAsctbTerms(this.asctbTerms); + this.store.setTermsIndex(this.termsIndex); this.searchControl.valueChanges.subscribe((result?: string) => { - this.onSearchChange(result === '' ? undefined : result); + this.store.updateFilters({ ...this.store.filters(), searchTerm: result === '' ? undefined : result }); + this.updateQueryParamsFromFilters(); }); effect(() => { + this.filteredRows.set(this.store.allRows()); this.populateFilterOptions(); - this.digitalObjectSearch().subscribe((results) => { - const newFilteredRows = this.filter.allRows().filter((row) => results.includes(row['purl'] as string)); - this.filteredRows.set(newFilteredRows); - }); + this.attachDownloadOptions(); }); effect(() => { - this.attachDownloadOptions(); + this.digitalObjectSearch().subscribe((results) => { + const newFilteredRows = this.store.allRows().filter((row) => results.includes(row['purl'] as string)); + this.filteredRows.set(newFilteredRows); + }); }); this.setScrollViewportHeight(); @@ -162,7 +140,7 @@ export class MainPageComponent { * Sets filters from query params in the url * @param queryParams Query params from the route */ - private setFiltersFomParams(queryParams: Params) { + private setFiltersFromQueryParams(queryParams: Params) { const dObjects = queryParams['do']; const versions = queryParams['versions']; const organs = queryParams['organs']; @@ -180,16 +158,7 @@ export class MainPageComponent { biomarkers: handleValue(b) || [], searchTerm: search ?? '', }); - this.searchControl.patchValue(this.store.searchTerm()); - } - - /** - * Updates current filter selections when changed - * @param formControls - */ - handleFilterSelectionChanges(formValues: FilterFormValues) { - this.store.updateFiltersFromForm(formValues); - this.updateQueryParamsFromFilters(); + this.searchControl.patchValue(this.store.filters().searchTerm); } /** @@ -198,104 +167,38 @@ export class MainPageComponent { private updateQueryParamsFromFilters() { this.router.navigate([''], { queryParams: { - do: this.store.digitalObjects(), - versions: this.store.releaseVersion(), - organs: this.store.organs(), - as: this.store.anatomicalStructures(), - ct: this.store.cellTypes(), - b: this.store.biomarkers(), - search: this.store.searchTerm(), + do: this.store.filters().digitalObjects, + versions: this.store.filters().releaseVersion, + organs: this.store.filters().organs, + as: this.store.filters().anatomicalStructures, + ct: this.store.filters().cellTypes, + b: this.store.filters().biomarkers, + search: this.store.filters().searchTerm, }, }); } /** - * Populates filter categories with options - */ - private populateFilterOptions() { - this.filterCategories.update((categories) => { - return { - digitalObjects: { - ...categories['digitalObjects'], - options: this.filter.digitalObjectsOptions(), - }, - releaseVersions: { - ...categories['releaseVersions'], - options: this.filter.hraVersionsOptions(), - }, - organs: { - ...categories['organs'], - options: this.filter.generateOrganOptions(), - }, - anatomicalStructures: { - ...categories['anatomicalStructures'], - options: this.filter.generateAsctbOptions('AS', this.asctbTerms()), - }, - cellTypes: { - ...categories['cellTypes'], - options: this.filter.generateAsctbOptions('CT', this.asctbTerms()), - }, - biomarkers: { - ...categories['biomarkers'], - options: this.filter.generateAsctbOptions('BM', this.asctbTerms()), - }, - }; - }); - } - - /** - * Performs KG DO search for selected ontology, cell type, biomarker, and HRA release version filters - * @returns object search + * Updates current filter selections when changed + * @param formControls */ - private digitalObjectSearch(): Observable { - const currentOrganFilters = this.store.organs ? this.store.organs() : []; - const currentAnatomicalStructuresFilters = this.store.anatomicalStructures ? this.store.anatomicalStructures() : []; - const currentCellTypesFilters = this.store.cellTypes ? this.store.cellTypes() : []; - const currentBiomarkerFilters = this.store.biomarkers ? this.store.biomarkers() : []; - const currentHraVersionFilters = this.store.releaseVersion ? this.store.releaseVersion() : []; - const currentSearchTerm = this.store.searchTerm(); - const digitalObjects = this.store.digitalObjects() ? this.store.digitalObjects() : []; - - return this.filter.doSearch({ - organs: currentOrganFilters || [], - versions: currentHraVersionFilters || [], - ontologyTerms: currentAnatomicalStructuresFilters || [], - cellTypeTerms: currentCellTypesFilters || [], - biomarkerTerms: currentBiomarkerFilters || [], - searchTerm: currentSearchTerm, - digitalObjects: digitalObjects || [], - }); + handleFilterSelectionChanges(formValues: FilterFormValues) { + this.store.updateFiltersFromForm(formValues); + this.updateQueryParamsFromFilters(); } /** - * Resolves raw digital object data into array of TableRow - * @param data Raw digital object data - * @returns Data as TableRow[] + * Populates filter categories with options */ - private resolveData(data?: DigitalObjectInfo[]): TableRow[] { - if (!data) { - return []; - } - return data.map((item) => { - const organLabel = item.organs ? handleValue(item.organs)?.[0] : undefined; - return { - id: item.lod, - purl: item.purl, - doType: item.doType, - hraVersions: item.hraVersions, - doVersion: item.doVersion, - organIds: item.organIds, - title: item.title, - objectUrl: `${item.doType}/${item.doName}/latest`, - typeIcon: getProductIcon(item.doType), - typeTooltip: getProductLabel(item.doType), - organIcon: getOrganIcon(item), - organTooltip: sentenceCase(organLabel || 'All Organs'), - cellCount: item.cell_count, - biomarkerCount: item.biomarker_count, - lastPublished: this.formatDateToYYYYMM(item.lastUpdated['@value']), - } as TableRow; - }); + private populateFilterOptions() { + const keys = Object.keys(FILTER_CATEGORY_INFO); + const values = Object.values(FILTER_CATEGORY_INFO); + const categories = values.map((categoryInfo, index) => ({ + ...categoryInfo, + options: this.store.allFilters()[keys[index] as FilterType], + })); + + this.filterCategories.update(() => categories); } /** @@ -304,7 +207,7 @@ export class MainPageComponent { private attachDownloadOptions() { if (this.downloadId()) { this.http.get(this.downloadId() || '', { responseType: 'json' }).subscribe((data) => { - const match = this.filter.allRows().find((row) => row['id'] === this.downloadId()); + const match = this.store.allRows().find((row) => row['id'] === this.downloadId()); if (match) { match['downloadOptions'] = this.download.getDownloadOptions(data as DigitalObjectMetadata); } @@ -313,25 +216,19 @@ export class MainPageComponent { } /** - * Updates filteredRows on searchTerm input - * @param searchTerm Search input - */ - private onSearchChange(searchTerm?: string): void { - this.store.updateSearchTerm(searchTerm); - - this.updateQueryParamsFromFilters(); - } - - /** - * Formats Date to yyyy-mm - * @param dateString Date string - * @returns Date as yyyy-mm + * Performs KG DO search for selected ontology, cell type, biomarker, and HRA release version filters + * @returns object search */ - private formatDateToYYYYMM(dateString: string): string { - const date = new Date(dateString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - return `${year}-${month}`; + private digitalObjectSearch(): Observable { + return this.search.doSearch({ + digitalObjects: this.store.filters().digitalObjects ?? [], + versions: this.store.filters().releaseVersion ?? [], + organs: this.store.filters().organs ?? [], + ontologyTerms: this.store.filters().anatomicalStructures ?? [], + cellTypeTerms: this.store.filters().cellTypes ?? [], + biomarkerTerms: this.store.filters().biomarkers ?? [], + searchTerm: this.store.filters().searchTerm, + }); } /** diff --git a/apps/kg-explorer/src/app/services/filter.service.ts b/apps/kg-explorer/src/app/services/filter.service.ts deleted file mode 100644 index 4925374b31..0000000000 --- a/apps/kg-explorer/src/app/services/filter.service.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { inject, Injectable, signal } from '@angular/core'; -import { TableRow } from '@hra-ui/design-system/table'; -import { from, Observable } from 'rxjs'; -import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; -import { - FilterOption, - getProductLabel, - getProductTooltip, - handleValue, - HRA_VERSION_DATA, - sentenceCase, -} from '../utils/utils'; -import { FiltersStore } from '../state/filters.store'; - -/** Digital object info interface with hraVersions */ -export interface DigitalObjectInfoWithHraVersions extends DigitalObjectInfo { - /** List of HRA versions for the object */ - hraVersions: string[]; -} - -@Injectable({ - providedIn: 'root', -}) -export class FilterService { - readonly data = signal({ '@context': {}, '@graph': [] }); - readonly allRows = signal([]); - readonly asctbTerms = signal([]); - readonly termsIndex = signal({ terms: [], purls: [], term_to_purls: [], purl_to_terms: [] }); - /** Records HRA version counts for the version filter */ - readonly versionCounts = signal>({}); - - readonly store = inject(FiltersStore); - - /** - * Sets the version filter counts from the data - * @param data Digital object data - */ - setVersionCounts(data: DigitalObjectInfoWithHraVersions[]) { - const result: Record = {}; - const allVersions = data.map((object) => object.hraVersions); - const flatVersions = allVersions.flat(); - for (const version of flatVersions) { - if (result[version]) { - result[version] += 1; - } else { - result[version] = 1; - } - } - this.versionCounts.set(result); - } - - /** - * Returns unique filter options for digital objects, versions, and organs from KG API data - */ - kgFilterOptions() { - const objectFilterOptions = new Set(); - const organFilterOptions = new Set(); - this.allRows().forEach((row) => { - const type = row['doType']; - objectFilterOptions.add(type as string); - const organs = handleValue(row['organIds'] as string[] | string | undefined); - if (organs) { - for (const organ of organs) { - organFilterOptions.add(organ); - } - } - }); - return { - doOptions: objectFilterOptions, - organOptions: organFilterOptions, - }; - } - - /** - * Returns list of digital objects in the data as filter options - * @returns Filter options - */ - digitalObjectsOptions(): FilterOption[] { - return Array.from(this.kgFilterOptions().doOptions) - .map((filterOption) => { - return { - id: filterOption, - label: getProductLabel(filterOption), - count: this.calculateCount(filterOption, 'doType'), - tooltip: getProductTooltip(filterOption), - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)); - } - - /** - * Returns HRA version data as filter options - * @returns Filter options - */ - hraVersionsOptions(): FilterOption[] { - return Object.keys(HRA_VERSION_DATA) - .map((filterOption) => { - const versionData = HRA_VERSION_DATA[filterOption]; - return { - id: filterOption, - label: versionData ? versionData.label : filterOption, - count: this.versionCounts()[filterOption], - secondaryLabel: versionData ? versionData.date : undefined, - }; - }) - .sort((o1, o2) => o2.id.localeCompare(o1.id)); //Reverse order - } - - /** - * Returns list of organs in the data as filter options - * @returns Filter options - */ - generateOrganOptions(): FilterOption[] { - return Array.from(this.kgFilterOptions().organOptions) - .map((organOption) => { - return { - id: organOption, - label: sentenceCase(this.asctbTerms().find((term) => term.iri === organOption)?.label ?? organOption), - count: this.calculateCount(organOption, 'organIds'), - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)); - } - - generateAsctbOptions(type: string, objects: AsctbTerms): FilterOption[] { - return objects - .filter((term) => term.asctb_type === type) - .map((term) => { - return { - id: term.iri, - label: term.label, - count: this.termsIndex().term_to_purls[this.termsIndex().terms.indexOf(term.iri)]?.length ?? 0, - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)); - } - - calculateCount(filterOption: string, category: string): number { - return this.allRows().filter((row) => { - const cat = handleValue(row[category] as string[] | string | undefined); - if (cat) { - return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); - } - return cat === filterOption; - }).length; - } - - doSearch(options: { - organs: string[]; - versions: string[]; - ontologyTerms: string[]; - cellTypeTerms: string[]; - biomarkerTerms: string[]; - searchTerm: string | undefined; - digitalObjects: string[]; - }): Observable { - const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms, searchTerm, digitalObjects } = options; - let result = []; - if ( - organs.length === 0 && - versions.length === 0 && - ontologyTerms.length === 0 && - cellTypeTerms.length === 0 && - biomarkerTerms.length === 0 && - digitalObjects.length === 0 && - !searchTerm - ) { - result = this.allRows().map((row) => row['purl'] as string); - } else { - const filteredByDigitalObjects = this.allRows().filter((row) => { - const type = row['doType'] as string; - return digitalObjects.includes(type); - }); - const filteredByVersions = this.allRows().filter((row) => { - const rowVersions = handleValue(row['hraVersions'] as string[] | string | undefined); - if (rowVersions) { - return rowVersions?.some((version) => versions.includes(version)); - } - return false; - }); - const filteredBySearchTerm = this.allRows().filter((row) => { - const title = row['title'] as string; - return searchTerm ? title.toLowerCase().includes((searchTerm || '').toLowerCase()) : false; - }); - const doSet = new Set(filteredByDigitalObjects.map((row) => row['purl'] as string)); - const versionsSet = new Set(filteredByVersions.map((row) => row['purl'] as string)); - const searchResults = new Set(filteredBySearchTerm.map((row) => row['purl'] as string)); - - const filteredPurls = new Set([ - ...doSet, - ...versionsSet, - ...searchResults, - ...this.getPurlsFromTerms(organs), - ...this.getPurlsFromTerms(ontologyTerms), - ...this.getPurlsFromTerms(cellTypeTerms), - ...this.getPurlsFromTerms(biomarkerTerms), - ]); - result = Array.from(filteredPurls); - } - return from([result]); - } - - filterSearchFormResults(currentResults: string[], searchTerm: string | undefined): string[] { - return currentResults.filter((entry) => { - return entry.toLowerCase().includes((searchTerm || '').toLowerCase()); - }); - } - - filterDigitalObjectResults(currentResults: string[]): string[] { - const currentDigitalObjectsFilters = this.store.digitalObjects(); - if (currentDigitalObjectsFilters && currentDigitalObjectsFilters.length === 0) { - return currentResults; - } - return currentResults.filter((row) => currentDigitalObjectsFilters?.includes(row as string)); - } - - getPurlsFromTerms(terms: string[]): Set { - const purls = new Set(); - terms.forEach((term) => { - const purlIndexes = this.termsIndex().term_to_purls[this.termsIndex().terms.indexOf(term)]; - if (purlIndexes) { - purlIndexes.forEach((purlIndex) => purls.add(this.termsIndex().purls[purlIndex])); - } - }); - return purls; - } -} diff --git a/apps/kg-explorer/src/app/services/search.service.ts b/apps/kg-explorer/src/app/services/search.service.ts new file mode 100644 index 0000000000..dbcca8e790 --- /dev/null +++ b/apps/kg-explorer/src/app/services/search.service.ts @@ -0,0 +1,88 @@ +import { inject, Injectable } from '@angular/core'; +import { from, Observable } from 'rxjs'; +import { FiltersStore } from '../state/filters.store'; +import { handleValue } from '../utils/utils'; + +@Injectable({ + providedIn: 'root', +}) +export class SearchService { + readonly store = inject(FiltersStore); + + doSearch(options: { + organs: string[]; + versions: string[]; + ontologyTerms: string[]; + cellTypeTerms: string[]; + biomarkerTerms: string[]; + searchTerm: string | undefined; + digitalObjects: string[]; + }): Observable { + const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms, searchTerm, digitalObjects } = options; + + const filteredByDigitalObjects = this.store.allRows().filter((row) => { + const type = row['doType'] as string; + if (digitalObjects.length === 0) { + return true; + } + return digitalObjects.includes(type); + }); + + const filteredByVersions = filteredByDigitalObjects + .filter((row) => { + const rowVersions = handleValue(row['hraVersions'] as string[] | string | undefined); + if (versions.length === 0) { + return true; + } + if (rowVersions) { + return rowVersions?.some((version) => versions.includes(version)); + } + return false; + }) + .map((row) => row['purl'] as string); + + const filteredBySearchTerm = filteredByVersions.filter((entry) => { + return entry.toLowerCase().includes((searchTerm || '').toLowerCase()); + }); + + const filteredByOrgans = filteredBySearchTerm.filter((term) => { + if (organs.length === 0) { + return true; + } + return this.getPurlsFromTerms(organs).has(term); + }); + + const filteredByOntologyTerms = filteredByOrgans.filter((term) => { + if (ontologyTerms.length === 0) { + return true; + } + return this.getPurlsFromTerms(ontologyTerms).has(term); + }); + const filteredByCellTypeTerms = filteredByOntologyTerms.filter((term) => { + if (cellTypeTerms.length === 0) { + return true; + } + return this.getPurlsFromTerms(cellTypeTerms).has(term); + }); + + const filteredByBiomarkerTerms = filteredByCellTypeTerms.filter((term) => { + if (biomarkerTerms.length === 0) { + return true; + } + return this.getPurlsFromTerms(biomarkerTerms).has(term); + }); + + return from([filteredByBiomarkerTerms]); + } + + getPurlsFromTerms(terms: string[]): Set { + const purls = new Set(); + terms.forEach((term) => { + const purlIndexes = this.store.termsIndex().term_to_purls[this.store.termsIndex().terms.indexOf(term)]; + if (purlIndexes) { + purlIndexes.forEach((purlIndex) => purls.add(this.store.termsIndex().purls[purlIndex])); + } + }); + return purls; + } +} diff --git a/apps/kg-explorer/src/app/state/with-filters.feature.ts b/apps/kg-explorer/src/app/state/with-filters.feature.ts index 97bd14d62b..bf6dcb6b58 100644 --- a/apps/kg-explorer/src/app/state/with-filters.feature.ts +++ b/apps/kg-explorer/src/app/state/with-filters.feature.ts @@ -1,6 +1,20 @@ import { computed } from '@angular/core'; +import { TableRow } from '@hra-ui/design-system/table'; import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals'; import { FilterFormValues } from '../components/filter-menu/filter-menu.component'; +import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; +import { + FilterOption, + FilterOptions, + formatDateToYYYYMM, + getOrganIcon, + getProductIcon, + getProductLabel, + getProductTooltip, + handleValue, + HRA_VERSION_DATA, + sentenceCase, +} from '../utils/utils'; /** Current filter interface (each category contains string of filter option IDs) */ export interface CurrentFilters { @@ -20,39 +34,211 @@ export interface CurrentFilters { searchTerm: string | undefined; } +export interface FiltersState { + data: DigitalObjectsJsonLd; + asctbTerms: AsctbTerms; + termsIndex: TermsIndex; + allFilters: FilterOptions; + allRows: TableRow[]; + filters: CurrentFilters; +} + +/** + * Resolves raw digital object data into array of TableRow + * @param data Raw digital object data + * @returns Data as TableRow[] + */ +function resolveData(data?: DigitalObjectInfo[]): TableRow[] { + if (!data) { + return []; + } + return data.map((item) => { + const organLabel = item.organs ? handleValue(item.organs)?.[0] : undefined; + return { + id: item.lod, + purl: item.purl, + doType: item.doType, + hraVersions: item.hraVersions, + doVersion: item.doVersion, + organIds: item.organIds, + title: item.title, + objectUrl: `${item.doType}/${item.doName}/latest`, + typeIcon: getProductIcon(item.doType), + typeTooltip: getProductLabel(item.doType), + organIcon: getOrganIcon(item), + organTooltip: sentenceCase(organLabel || 'All Organs'), + cellCount: item.cell_count, + biomarkerCount: item.biomarker_count, + lastPublished: formatDateToYYYYMM(item.lastUpdated['@value']), + } as TableRow; + }); +} + +function getVersionCounts(data: DigitalObjectInfo[]): Record { + const result: Record = {}; + const allVersions = data.map((object) => object.hraVersions); + const flatVersions = allVersions.flat(); + for (const version of flatVersions) { + if (version) { + if (result[version]) { + result[version] += 1; + } else { + result[version] = 1; + } + } + } + return result; +} + +function calculateCount(filterOption: string, category: string, rows: TableRow[]): number { + return rows.filter((row) => { + const cat = handleValue(row[category] as string[] | string | undefined); + if (cat) { + return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); + } + return cat === filterOption; + }).length; +} + +function generateAsctbOptions(type: string, objects: AsctbTerms, termsIndex: TermsIndex): FilterOption[] { + return objects + .filter((term) => term.asctb_type === type) + .map((term) => { + return { + id: term.iri, + label: term.label, + count: termsIndex.term_to_purls[termsIndex.terms.indexOf(term.iri)]?.length ?? 0, + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)); +} + /** Initial state for the filters store */ -const initialState: CurrentFilters = { - digitalObjects: [], - releaseVersion: [], - organs: [], - anatomicalStructures: [], - cellTypes: [], - biomarkers: [], - searchTerm: undefined, +const initialState: FiltersState = { + data: { '@context': {}, '@graph': [] }, + allRows: [], + asctbTerms: [], + termsIndex: { terms: [], purls: [], term_to_purls: [], purl_to_terms: [] }, + filters: { + digitalObjects: [], + releaseVersion: [], + organs: [], + anatomicalStructures: [], + cellTypes: [], + biomarkers: [], + searchTerm: undefined, + }, + allFilters: { + digitalObjects: [], + releaseVersion: [], + organs: [], + anatomicalStructures: [], + cellTypes: [], + biomarkers: [], + }, }; export function withFilters() { return signalStoreFeature( withState(initialState), withComputed((store) => { + const allRows = computed(() => { + return resolveData(store.data()['@graph'] as DigitalObjectInfo[]); + }); + + const versionCounts = computed(() => { + return getVersionCounts(store.data()['@graph'] as DigitalObjectInfo[]); + }); + + const kgFilterOptions = computed(() => { + const objectFilterOptions = new Set(); + const organFilterOptions = new Set(); + allRows().forEach((row) => { + const type = row['doType']; + objectFilterOptions.add(type as string); + const organs = handleValue(row['organIds'] as string[] | string | undefined); + if (organs) { + for (const organ of organs) { + organFilterOptions.add(organ); + } + } + }); + return { + doOptions: objectFilterOptions, + organOptions: organFilterOptions, + }; + }); + + const _digitalObjectsOptions = computed(() => { + return Array.from(kgFilterOptions().doOptions) + .map((filterOption) => { + return { + id: filterOption, + label: getProductLabel(filterOption), + count: calculateCount(filterOption, 'doType', allRows()), + tooltip: getProductTooltip(filterOption), + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)) as FilterOption[]; + }); + + const _hraVersionsOptions = computed(() => { + return Object.keys(HRA_VERSION_DATA) + .map((filterOption) => { + const versionData = HRA_VERSION_DATA[filterOption]; + return { + id: filterOption, + label: versionData ? versionData.label : filterOption, + count: versionCounts()[filterOption], + secondaryLabel: versionData ? versionData.date : undefined, + }; + }) + .sort((o1, o2) => o2.id.localeCompare(o1.id)) as FilterOption[]; //Reverse order + }); + + const _organOptions = computed(() => { + return Array.from(kgFilterOptions().organOptions) + .map((organOption) => { + return { + id: organOption, + label: sentenceCase(store.asctbTerms().find((term) => term.iri === organOption)?.label ?? organOption), + count: calculateCount(organOption, 'organIds', allRows()), + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)) as FilterOption[]; + }); + + const _anatomicalStructuresOptions = computed(() => + generateAsctbOptions('AS', store.asctbTerms(), store.termsIndex()), + ); + const _cellTypesOptions = computed(() => generateAsctbOptions('CT', store.asctbTerms(), store.termsIndex())); + const _biomarkerOptions = computed(() => generateAsctbOptions('BM', store.asctbTerms(), store.termsIndex())); + + const allFilters = computed(() => { + return { + digitalObjects: _digitalObjectsOptions(), + releaseVersion: _hraVersionsOptions(), + organs: _organOptions(), + anatomicalStructures: _anatomicalStructuresOptions(), + cellTypes: _cellTypesOptions(), + biomarkers: _biomarkerOptions(), + }; + }); + return { - filters: computed(() => { - return { - digitalObjects: store.digitalObjects(), - releaseVersion: store.releaseVersion(), - organs: store.organs(), - anatomicalStructures: store.anatomicalStructures(), - cellTypes: store.cellTypes(), - biomarkers: store.biomarkers(), - searchTerm: store.searchTerm(), - }; - }), + allRows, + allFilters, }; }), withMethods((store) => ({ updateFilters: signalMethod((filters: CurrentFilters) => { - patchState(store, filters); + patchState(store, { filters }); }), + + setData: signalMethod((data: DigitalObjectsJsonLd) => patchState(store, { data })), + setAsctbTerms: signalMethod((asctbTerms: AsctbTerms) => patchState(store, { asctbTerms })), + setTermsIndex: signalMethod((termsIndex: TermsIndex) => patchState(store, { termsIndex })), + updateFiltersFromForm: signalMethod((formValues: FilterFormValues) => { const updatedFilters: CurrentFilters = { digitalObjects: formValues.digitalObjects?.map((obj) => obj.id) || [], @@ -61,13 +247,9 @@ export function withFilters() { anatomicalStructures: formValues.anatomicalStructures?.map((obj) => obj.id) || [], cellTypes: formValues.cellTypes?.map((obj) => obj.id) || [], biomarkers: formValues.biomarkers?.map((obj) => obj.id) || [], - searchTerm: store.searchTerm(), + searchTerm: store.filters().searchTerm, }; - patchState(store, updatedFilters); - }), - - updateSearchTerm: signalMethod((searchTerm?: string) => { - patchState(store, { searchTerm }); + patchState(store, { filters: updatedFilters }); }), })), ); diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index 385bbf7fe5..a9236c5706 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -46,8 +46,26 @@ export interface FilterOption { tooltip?: TooltipData; } +export interface FilterOptions { + /** Digital object filters */ + digitalObjects: FilterOption[]; + /** Release version filters */ + releaseVersion: FilterOption[]; + /** Organ filters */ + organs: FilterOption[]; + /** Anatomical structures filters */ + anatomicalStructures: FilterOption[]; + /** Cell type filters */ + cellTypes: FilterOption[]; + /** Biomarker filters */ + biomarkers: FilterOption[]; +} + +/** Filter types for the filter form */ +export type FilterType = keyof FilterOptions; + /** Filter category info */ -export const FILTER_CATEGORY_INFO: Record = { +export const FILTER_CATEGORY_INFO: Record = { digitalObjects: { label: 'Digital objects', tooltip: { @@ -56,7 +74,7 @@ export const FILTER_CATEGORY_INFO: Record = { actionUrl: 'https://humanatlas.io/overview-data', }, }, - releaseVersions: { + releaseVersion: { label: 'HRA release version', tooltip: { description: 'New and updated data is released twice a year on June 15 and December 15.', @@ -370,3 +388,15 @@ export function handleValue(value: string | string[] | undefined): string[] | un } return value; } + +/** + * Formats Date to yyyy-mm + * @param dateString Date string + * @returns Date as yyyy-mm + */ +export function formatDateToYYYYMM(dateString: string): string { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +} From ac3fd830ff10f041139f0b096851f9f584698585 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Mon, 20 Apr 2026 18:57:37 -0400 Subject: [PATCH 06/19] Enable mirror url input to app --- apps/kg-explorer/src/app/app.component.ts | 30 ++++++++------- apps/kg-explorer/src/app/app.routes.ts | 38 ++++++++++++++----- .../src/environments/environment.staging.ts | 2 +- .../src/environments/environment.ts | 2 +- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/apps/kg-explorer/src/app/app.component.ts b/apps/kg-explorer/src/app/app.component.ts index 0206293ac1..59891e5660 100644 --- a/apps/kg-explorer/src/app/app.component.ts +++ b/apps/kg-explorer/src/app/app.component.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, input, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDividerModule } from '@angular/material/divider'; import { MatMenuModule } from '@angular/material/menu'; @@ -11,7 +11,7 @@ import { NavigationModule } from '@hra-ui/design-system/navigation'; import { MarkdownModule } from 'ngx-markdown'; import { HelpMenuOptions } from './app.routes'; import { DigitalObjectsJsonLd } from './digital-objects-metadata.schema'; -import { setMirrorUrl, setRemoteApiEndpoint } from './utils/endpoints'; +import { injectMirrorUrl, setMirrorUrl, setRemoteApiEndpoint } from './utils/endpoints'; import { isNavigating } from './utils/navigation'; import { routeData } from './utils/route-data'; @@ -70,6 +70,10 @@ export class AppComponent extends BaseApplicationComponent { private readonly http = inject(HttpClient); + readonly customMirror = input(); + + readonly mirrorUrl = injectMirrorUrl(); + /** Page title to display on the breadcrumbs */ private readonly pageTitle = signal(''); @@ -118,6 +122,16 @@ export class AppComponent extends BaseApplicationComponent { constructor() { super({ screenSizeNotice: { width: 864, height: 486 } }); + const el = inject(ElementRef).nativeElement as HTMLElement; + const apiEndpoint = el.getAttribute('remote-api-endpoint'); + if (apiEndpoint) { + setRemoteApiEndpoint(apiEndpoint); + } + const mirrorUrl = el.getAttribute('custom-mirror') || el.getAttribute('mirror-url'); + if (mirrorUrl) { + setMirrorUrl(mirrorUrl); + } + effect(() => { if (this.typeLabel() && this.documentationUrl()) { this.extraMenuOption.set({ @@ -151,20 +165,10 @@ export class AppComponent extends BaseApplicationComponent { this.setPageTitle(); }); - - const el = inject(ElementRef).nativeElement as HTMLElement; - const apiEndpoint = el.getAttribute('remote-api-endpoint'); - if (apiEndpoint) { - setRemoteApiEndpoint(apiEndpoint); - } - const mirrorUrl = el.getAttribute('mirror-url'); - if (mirrorUrl) { - setMirrorUrl(mirrorUrl); - } } private setPageTitle() { - this.http.get('https://cdn.humanatlas.io/digital-objects/kg/digital-objects.jsonld').subscribe((data) => { + this.http.get(`${this.mirrorUrl()}/kg/digital-objects.jsonld`).subscribe((data) => { this.digitalObjects.set(data as DigitalObjectsJsonLd); const id = this.objectId(); const objects = this.digitalObjects(); diff --git a/apps/kg-explorer/src/app/app.routes.ts b/apps/kg-explorer/src/app/app.routes.ts index 495b871810..287daad451 100644 --- a/apps/kg-explorer/src/app/app.routes.ts +++ b/apps/kg-explorer/src/app/app.routes.ts @@ -1,4 +1,4 @@ -import { Route } from '@angular/router'; +import { ActivatedRouteSnapshot, Route, RouterStateSnapshot } from '@angular/router'; import { NotFoundPageComponent } from '@hra-ui/design-system/error-pages/not-found-page'; import { ServerErrorPageComponent } from '@hra-ui/design-system/error-pages/server-error-page'; import { TableColumn } from '@hra-ui/design-system/table'; @@ -8,6 +8,7 @@ import { AsctbTermsSchema, DigitalObjectsJsonLdSchema, TermsIndexSchema } from ' import { MainPageComponent } from './pages/main-page/main-page.component'; import { MetadataPageComponent } from './pages/metadata-page/metadata-page.component'; import { documentationUrlResolver, doMetadataResolver, productLabelResolver } from './utils/kg-resolver'; +import { injectMirrorUrl } from './utils/endpoints'; /** Column info for digital object table */ export const DO_COLUMNS: TableColumn[] = [ @@ -93,10 +94,6 @@ export interface HelpMenuOptions { icon?: string; } -const DO_URL = 'https://cdn.humanatlas.io/digital-objects/kg/digital-objects.jsonld'; -const ASCTB_TERMS_URL = 'https://cdn.humanatlas.io/digital-objects/kg/asctb-terms.json'; -const KG_TERMS_INDEX_URL = 'https://cdn.humanatlas.io/digital-objects/kg/kg-terms-index.json'; - /** Application routes */ export const appRoutes: Route[] = [ { @@ -108,9 +105,21 @@ export const appRoutes: Route[] = [ columns: DO_COLUMNS, }, resolve: { - data: createJsonSpecResolver(DO_URL, DigitalObjectsJsonLdSchema), - asctbTerms: createJsonSpecResolver(ASCTB_TERMS_URL, AsctbTermsSchema), - termsIndex: createJsonSpecResolver(KG_TERMS_INDEX_URL, TermsIndexSchema), + data: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const mirrorUrl = injectMirrorUrl(); + return createJsonSpecResolver(`${mirrorUrl()}/kg/digital-objects.jsonld`, DigitalObjectsJsonLdSchema)( + route, + state, + ); + }, + asctbTerms: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const mirrorUrl = injectMirrorUrl(); + return createJsonSpecResolver(`${mirrorUrl()}/kg/asctb-terms.json`, AsctbTermsSchema)(route, state); + }, + termsIndex: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const mirrorUrl = injectMirrorUrl(); + return createJsonSpecResolver(`${mirrorUrl()}/kg/kg-terms-index.json`, TermsIndexSchema)(route, state); + }, }, }, { @@ -120,8 +129,17 @@ export const appRoutes: Route[] = [ columns: METADATA_COLUMNS, }, resolve: { - doData: createJsonSpecResolver(DO_URL, DigitalObjectsJsonLdSchema), - asctbTerms: createJsonSpecResolver(ASCTB_TERMS_URL, AsctbTermsSchema), + doData: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const mirrorUrl = injectMirrorUrl(); + return createJsonSpecResolver(`${mirrorUrl()}/kg/digital-objects.jsonld`, DigitalObjectsJsonLdSchema)( + route, + state, + ); + }, + asctbTerms: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const mirrorUrl = injectMirrorUrl(); + return createJsonSpecResolver(`${mirrorUrl()}/kg/asctb-terms.json`, AsctbTermsSchema)(route, state); + }, metadata: doMetadataResolver(), documentationUrl: documentationUrlResolver(), typeLabel: productLabelResolver(), diff --git a/apps/kg-explorer/src/environments/environment.staging.ts b/apps/kg-explorer/src/environments/environment.staging.ts index 265fc21685..f1cf7fb0c3 100644 --- a/apps/kg-explorer/src/environments/environment.staging.ts +++ b/apps/kg-explorer/src/environments/environment.staging.ts @@ -2,5 +2,5 @@ export const environment = { production: true, remoteApiEndpoint: 'https://apps.humanatlas.io/api--staging', - mirrorUrl: 'https://cdn.humanatlas.io/hra-kg--staging', + mirrorUrl: 'https://cdn.humanatlas.io/digital-objects', }; diff --git a/apps/kg-explorer/src/environments/environment.ts b/apps/kg-explorer/src/environments/environment.ts index a008bf2c8e..960af2668f 100644 --- a/apps/kg-explorer/src/environments/environment.ts +++ b/apps/kg-explorer/src/environments/environment.ts @@ -2,5 +2,5 @@ export const environment = { production: false, remoteApiEndpoint: 'https://apps.humanatlas.io/api--staging', - mirrorUrl: 'https://cdn.humanatlas.io/hra-kg--staging', + mirrorUrl: 'https://cdn.humanatlas.io/digital-objects', }; From 71dc4eef411240592b1744e8b5115da4007e7651 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Tue, 21 Apr 2026 14:03:06 -0400 Subject: [PATCH 07/19] Don't reference store in search service --- .../pages/main-page/main-page.component.ts | 5 +-- .../src/app/services/search.service.ts | 45 ++++++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index 6b1fb729f5..0b17a5e92d 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -25,8 +25,7 @@ import { import { DownloadService } from '../../services/download.service'; import { SearchService } from '../../services/search.service'; import { FiltersStore } from '../../state/filters.store'; -import { FILTER_CATEGORY_INFO, FilterOptionCategory, FilterOptions, FilterType, handleValue } from '../../utils/utils'; -import { ca } from 'zod/v4/locales'; +import { FILTER_CATEGORY_INFO, FilterOptionCategory, FilterType, handleValue } from '../../utils/utils'; /** Amount in pixels to move scrollbar downwards so it doesn't start at the header */ const SCROLLBAR_TOP_OFFSET = '86'; @@ -220,7 +219,7 @@ export class MainPageComponent { * @returns object search */ private digitalObjectSearch(): Observable { - return this.search.doSearch({ + return this.search.doSearch(this.store.allRows(), this.store.termsIndex(), { digitalObjects: this.store.filters().digitalObjects ?? [], versions: this.store.filters().releaseVersion ?? [], organs: this.store.filters().organs ?? [], diff --git a/apps/kg-explorer/src/app/services/search.service.ts b/apps/kg-explorer/src/app/services/search.service.ts index dbcca8e790..bd60a97cca 100644 --- a/apps/kg-explorer/src/app/services/search.service.ts +++ b/apps/kg-explorer/src/app/services/search.service.ts @@ -1,26 +1,29 @@ -import { inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; +import { TableRow } from '@hra-ui/design-system/table'; import { from, Observable } from 'rxjs'; -import { FiltersStore } from '../state/filters.store'; +import { TermsIndex } from '../digital-objects-metadata.schema'; import { handleValue } from '../utils/utils'; @Injectable({ providedIn: 'root', }) export class SearchService { - readonly store = inject(FiltersStore); - - doSearch(options: { - organs: string[]; - versions: string[]; - ontologyTerms: string[]; - cellTypeTerms: string[]; - biomarkerTerms: string[]; - searchTerm: string | undefined; - digitalObjects: string[]; - }): Observable { + doSearch( + rows: TableRow[], + termsIndex: TermsIndex, + options: { + organs: string[]; + versions: string[]; + ontologyTerms: string[]; + cellTypeTerms: string[]; + biomarkerTerms: string[]; + searchTerm: string | undefined; + digitalObjects: string[]; + }, + ): Observable { const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms, searchTerm, digitalObjects } = options; - const filteredByDigitalObjects = this.store.allRows().filter((row) => { + const filteredByDigitalObjects = rows.filter((row) => { const type = row['doType'] as string; if (digitalObjects.length === 0) { return true; @@ -49,38 +52,38 @@ export class SearchService { if (organs.length === 0) { return true; } - return this.getPurlsFromTerms(organs).has(term); + return this.getPurlsFromTerms(organs, termsIndex).has(term); }); const filteredByOntologyTerms = filteredByOrgans.filter((term) => { if (ontologyTerms.length === 0) { return true; } - return this.getPurlsFromTerms(ontologyTerms).has(term); + return this.getPurlsFromTerms(ontologyTerms, termsIndex).has(term); }); const filteredByCellTypeTerms = filteredByOntologyTerms.filter((term) => { if (cellTypeTerms.length === 0) { return true; } - return this.getPurlsFromTerms(cellTypeTerms).has(term); + return this.getPurlsFromTerms(cellTypeTerms, termsIndex).has(term); }); const filteredByBiomarkerTerms = filteredByCellTypeTerms.filter((term) => { if (biomarkerTerms.length === 0) { return true; } - return this.getPurlsFromTerms(biomarkerTerms).has(term); + return this.getPurlsFromTerms(biomarkerTerms, termsIndex).has(term); }); return from([filteredByBiomarkerTerms]); } - getPurlsFromTerms(terms: string[]): Set { + getPurlsFromTerms(terms: string[], termsIndex: TermsIndex): Set { const purls = new Set(); terms.forEach((term) => { - const purlIndexes = this.store.termsIndex().term_to_purls[this.store.termsIndex().terms.indexOf(term)]; + const purlIndexes = termsIndex.term_to_purls[termsIndex.terms.indexOf(term)]; if (purlIndexes) { - purlIndexes.forEach((purlIndex) => purls.add(this.store.termsIndex().purls[purlIndex])); + purlIndexes.forEach((purlIndex) => purls.add(termsIndex.purls[purlIndex])); } }); return purls; From 87a243d44e07cef17fc1a6b9eecd625982d0a589 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Tue, 21 Apr 2026 15:28:51 -0400 Subject: [PATCH 08/19] Tweaks and cleaning up --- .../filter-menu-overlay.component.ts | 2 +- .../filter-menu/filter-menu.component.ts | 19 +++- .../pages/main-page/main-page.component.html | 2 +- .../pages/main-page/main-page.component.ts | 50 +++++----- .../src/app/services/search.service.ts | 2 +- .../src/app/state/with-filters.feature.ts | 96 +++++++++---------- 6 files changed, 88 insertions(+), 83 deletions(-) diff --git a/apps/kg-explorer/src/app/components/filter-menu/filter-menu-overlay/filter-menu-overlay.component.ts b/apps/kg-explorer/src/app/components/filter-menu/filter-menu-overlay/filter-menu-overlay.component.ts index 6b8abd6ac7..ba365db2db 100644 --- a/apps/kg-explorer/src/app/components/filter-menu/filter-menu-overlay/filter-menu-overlay.component.ts +++ b/apps/kg-explorer/src/app/components/filter-menu/filter-menu-overlay/filter-menu-overlay.component.ts @@ -108,7 +108,7 @@ export class FilterMenuOverlayComponent implements OnInit { readonly filterOptionCategory = input.required(); /** Currently selected filter IDs */ - readonly currentFilters = input(); + readonly currentFilters = input(); /** Currently selected options */ readonly selectedOptions = signal([]); diff --git a/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts b/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts index 9802479e03..8ac45af175 100644 --- a/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts +++ b/apps/kg-explorer/src/app/components/filter-menu/filter-menu.component.ts @@ -8,7 +8,6 @@ import { IconsModule } from '@hra-ui/design-system/icons'; import { ScrollingModule } from '@hra-ui/design-system/scrolling'; import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip'; -import { CurrentFilters } from '../../state/with-filters.feature'; import { FilterOption, FilterOptionCategory, FilterType } from '../../utils/utils'; import { FilterMenuOverlayComponent } from './filter-menu-overlay/filter-menu-overlay.component'; @@ -28,6 +27,24 @@ export interface FilterFormValues { biomarkers: FilterOption[] | null; } +/** Current filter interface (each category contains string of filter option IDs) */ +export interface CurrentFilters { + /** Digital object filters */ + digitalObjects: string[] | null; + /** Release version filters */ + releaseVersion: string[] | null; + /** Organ filters */ + organs: string[] | null; + /** Anatomical structures filters */ + anatomicalStructures: string[] | null; + /** Cell type filters */ + cellTypes: string[] | null; + /** Biomarker filters */ + biomarkers: string[] | null; + /** Search term filters */ + searchTerm: string | null; +} + /** * Filter menu for the KG Explorer */ diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html index e3b6dce9f3..4b81907e97 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.html +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.html @@ -24,7 +24,7 @@ hraFeature="filter-menu" [filterCategories]="filterCategories()" [formClosed]="!sidenav.opened" - [currentFilters]="store.filters()" + [currentFilters]="store.currentFilters()" (toggleForm)="sidenav.toggle()" (formChanges)="handleFilterSelectionChanges($event); table.scrollToTop()" /> diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index 0b17a5e92d..178221c8a4 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -25,7 +25,7 @@ import { import { DownloadService } from '../../services/download.service'; import { SearchService } from '../../services/search.service'; import { FiltersStore } from '../../state/filters.store'; -import { FILTER_CATEGORY_INFO, FilterOptionCategory, FilterType, handleValue } from '../../utils/utils'; +import { FILTER_CATEGORY_INFO, FilterOptionCategory, FilterType } from '../../utils/utils'; /** Amount in pixels to move scrollbar downwards so it doesn't start at the header */ const SCROLLBAR_TOP_OFFSET = '86'; @@ -114,7 +114,7 @@ export class MainPageComponent { this.store.setTermsIndex(this.termsIndex); this.searchControl.valueChanges.subscribe((result?: string) => { - this.store.updateFilters({ ...this.store.filters(), searchTerm: result === '' ? undefined : result }); + this.store.setSearchTerm(result && result.length > 0 ? result : null); this.updateQueryParamsFromFilters(); }); @@ -148,16 +148,14 @@ export class MainPageComponent { const b = queryParams['b']; const search = queryParams['search']; - this.store.updateFilters({ - digitalObjects: dObjects, - releaseVersion: handleValue(versions) || [], - organs: handleValue(organs) || [], - anatomicalStructures: handleValue(as) || [], - cellTypes: handleValue(ct) || [], - biomarkers: handleValue(b) || [], - searchTerm: search ?? '', - }); - this.searchControl.patchValue(this.store.filters().searchTerm); + this.store.setDigitalObjects(dObjects); + this.store.setReleaseVersion(versions); + this.store.setOrgans(organs); + this.store.setAnatomicalStructures(as); + this.store.setCellTypes(ct); + this.store.setBiomarkers(b); + this.store.setSearchTerm(search); + this.searchControl.patchValue(search); } /** @@ -166,13 +164,13 @@ export class MainPageComponent { private updateQueryParamsFromFilters() { this.router.navigate([''], { queryParams: { - do: this.store.filters().digitalObjects, - versions: this.store.filters().releaseVersion, - organs: this.store.filters().organs, - as: this.store.filters().anatomicalStructures, - ct: this.store.filters().cellTypes, - b: this.store.filters().biomarkers, - search: this.store.filters().searchTerm, + do: this.store.digitalObjects(), + versions: this.store.releaseVersion(), + organs: this.store.organs(), + as: this.store.anatomicalStructures(), + ct: this.store.cellTypes(), + b: this.store.biomarkers(), + search: this.store.searchTerm(), }, }); } @@ -220,13 +218,13 @@ export class MainPageComponent { */ private digitalObjectSearch(): Observable { return this.search.doSearch(this.store.allRows(), this.store.termsIndex(), { - digitalObjects: this.store.filters().digitalObjects ?? [], - versions: this.store.filters().releaseVersion ?? [], - organs: this.store.filters().organs ?? [], - ontologyTerms: this.store.filters().anatomicalStructures ?? [], - cellTypeTerms: this.store.filters().cellTypes ?? [], - biomarkerTerms: this.store.filters().biomarkers ?? [], - searchTerm: this.store.filters().searchTerm, + digitalObjects: this.store.digitalObjects() ?? [], + versions: this.store.releaseVersion() ?? [], + organs: this.store.organs() ?? [], + ontologyTerms: this.store.anatomicalStructures() ?? [], + cellTypeTerms: this.store.cellTypes() ?? [], + biomarkerTerms: this.store.biomarkers() ?? [], + searchTerm: this.store.searchTerm(), }); } diff --git a/apps/kg-explorer/src/app/services/search.service.ts b/apps/kg-explorer/src/app/services/search.service.ts index bd60a97cca..1368303d22 100644 --- a/apps/kg-explorer/src/app/services/search.service.ts +++ b/apps/kg-explorer/src/app/services/search.service.ts @@ -17,8 +17,8 @@ export class SearchService { ontologyTerms: string[]; cellTypeTerms: string[]; biomarkerTerms: string[]; - searchTerm: string | undefined; digitalObjects: string[]; + searchTerm: string | null; }, ): Observable { const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms, searchTerm, digitalObjects } = options; diff --git a/apps/kg-explorer/src/app/state/with-filters.feature.ts b/apps/kg-explorer/src/app/state/with-filters.feature.ts index bf6dcb6b58..7977089242 100644 --- a/apps/kg-explorer/src/app/state/with-filters.feature.ts +++ b/apps/kg-explorer/src/app/state/with-filters.feature.ts @@ -5,7 +5,6 @@ import { FilterFormValues } from '../components/filter-menu/filter-menu.componen import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; import { FilterOption, - FilterOptions, formatDateToYYYYMM, getOrganIcon, getProductIcon, @@ -16,31 +15,17 @@ import { sentenceCase, } from '../utils/utils'; -/** Current filter interface (each category contains string of filter option IDs) */ -export interface CurrentFilters { - /** Digital object filters */ - digitalObjects: string[]; - /** Release version filters */ - releaseVersion: string[]; - /** Organ filters */ - organs: string[]; - /** Anatomical structures filters */ - anatomicalStructures: string[]; - /** Cell type filters */ - cellTypes: string[]; - /** Biomarker filters */ - biomarkers: string[]; - /** Search term filters */ - searchTerm: string | undefined; -} - export interface FiltersState { data: DigitalObjectsJsonLd; asctbTerms: AsctbTerms; termsIndex: TermsIndex; - allFilters: FilterOptions; - allRows: TableRow[]; - filters: CurrentFilters; + digitalObjects: string[] | null; + releaseVersion: string[] | null; + organs: string[] | null; + anatomicalStructures: string[] | null; + cellTypes: string[] | null; + biomarkers: string[] | null; + searchTerm: string | null; } /** @@ -116,32 +101,31 @@ function generateAsctbOptions(type: string, objects: AsctbTerms, termsIndex: Ter /** Initial state for the filters store */ const initialState: FiltersState = { data: { '@context': {}, '@graph': [] }, - allRows: [], asctbTerms: [], termsIndex: { terms: [], purls: [], term_to_purls: [], purl_to_terms: [] }, - filters: { - digitalObjects: [], - releaseVersion: [], - organs: [], - anatomicalStructures: [], - cellTypes: [], - biomarkers: [], - searchTerm: undefined, - }, - allFilters: { - digitalObjects: [], - releaseVersion: [], - organs: [], - anatomicalStructures: [], - cellTypes: [], - biomarkers: [], - }, + digitalObjects: null, + releaseVersion: null, + organs: null, + anatomicalStructures: null, + cellTypes: null, + biomarkers: null, + searchTerm: null, }; export function withFilters() { return signalStoreFeature( withState(initialState), withComputed((store) => { + const currentFilters = computed(() => ({ + digitalObjects: store.digitalObjects(), + releaseVersion: store.releaseVersion(), + organs: store.organs(), + anatomicalStructures: store.anatomicalStructures(), + cellTypes: store.cellTypes(), + biomarkers: store.biomarkers(), + searchTerm: store.searchTerm(), + })); + const allRows = computed(() => { return resolveData(store.data()['@graph'] as DigitalObjectInfo[]); }); @@ -228,28 +212,34 @@ export function withFilters() { return { allRows, allFilters, + currentFilters, }; }), withMethods((store) => ({ - updateFilters: signalMethod((filters: CurrentFilters) => { - patchState(store, { filters }); - }), + setDigitalObjects: signalMethod((digitalObjects: string[]) => patchState(store, { digitalObjects })), + setReleaseVersion: signalMethod((releaseVersion: string[]) => patchState(store, { releaseVersion })), + setOrgans: signalMethod((organs: string[]) => patchState(store, { organs })), + setAnatomicalStructures: signalMethod((anatomicalStructures: string[]) => + patchState(store, { anatomicalStructures }), + ), + setCellTypes: signalMethod((cellTypes: string[]) => patchState(store, { cellTypes })), + setBiomarkers: signalMethod((biomarkers: string[]) => patchState(store, { biomarkers })), + setSearchTerm: signalMethod((searchTerm: string | null) => patchState(store, { searchTerm })), setData: signalMethod((data: DigitalObjectsJsonLd) => patchState(store, { data })), setAsctbTerms: signalMethod((asctbTerms: AsctbTerms) => patchState(store, { asctbTerms })), setTermsIndex: signalMethod((termsIndex: TermsIndex) => patchState(store, { termsIndex })), updateFiltersFromForm: signalMethod((formValues: FilterFormValues) => { - const updatedFilters: CurrentFilters = { - digitalObjects: formValues.digitalObjects?.map((obj) => obj.id) || [], - releaseVersion: formValues.releaseVersion?.map((obj) => obj.id) || [], - organs: formValues.organs?.map((obj) => obj.id) || [], - anatomicalStructures: formValues.anatomicalStructures?.map((obj) => obj.id) || [], - cellTypes: formValues.cellTypes?.map((obj) => obj.id) || [], - biomarkers: formValues.biomarkers?.map((obj) => obj.id) || [], - searchTerm: store.filters().searchTerm, - }; - patchState(store, { filters: updatedFilters }); + patchState(store, { + digitalObjects: formValues.digitalObjects?.map((obj) => obj.id), + releaseVersion: formValues.releaseVersion?.map((obj) => obj.id), + organs: formValues.organs?.map((obj) => obj.id), + anatomicalStructures: formValues.anatomicalStructures?.map((obj) => obj.id), + cellTypes: formValues.cellTypes?.map((obj) => obj.id), + biomarkers: formValues.biomarkers?.map((obj) => obj.id), + searchTerm: store.searchTerm(), + }); }), })), ); From 75ea64e99d6945ac3f084846ef877b0a1f005432 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 22 Apr 2026 12:09:59 -0400 Subject: [PATCH 09/19] Implement custom JSON resolver for KG files --- apps/kg-explorer/src/app/app.routes.ts | 42 ++++++------------- apps/kg-explorer/src/app/utils/kg-resolver.ts | 25 ++++++++--- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/apps/kg-explorer/src/app/app.routes.ts b/apps/kg-explorer/src/app/app.routes.ts index 287daad451..869926d531 100644 --- a/apps/kg-explorer/src/app/app.routes.ts +++ b/apps/kg-explorer/src/app/app.routes.ts @@ -1,14 +1,17 @@ -import { ActivatedRouteSnapshot, Route, RouterStateSnapshot } from '@angular/router'; +import { Route } from '@angular/router'; import { NotFoundPageComponent } from '@hra-ui/design-system/error-pages/not-found-page'; import { ServerErrorPageComponent } from '@hra-ui/design-system/error-pages/server-error-page'; import { TableColumn } from '@hra-ui/design-system/table'; -import { createJsonSpecResolver } from '@hra-ui/design-system/content-templates/resolvers'; import { AsctbTermsSchema, DigitalObjectsJsonLdSchema, TermsIndexSchema } from './digital-objects-metadata.schema'; import { MainPageComponent } from './pages/main-page/main-page.component'; import { MetadataPageComponent } from './pages/metadata-page/metadata-page.component'; -import { documentationUrlResolver, doMetadataResolver, productLabelResolver } from './utils/kg-resolver'; -import { injectMirrorUrl } from './utils/endpoints'; +import { + documentationUrlResolver, + doMetadataResolver, + kgJsonResolver, + productLabelResolver, +} from './utils/kg-resolver'; /** Column info for digital object table */ export const DO_COLUMNS: TableColumn[] = [ @@ -105,21 +108,9 @@ export const appRoutes: Route[] = [ columns: DO_COLUMNS, }, resolve: { - data: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const mirrorUrl = injectMirrorUrl(); - return createJsonSpecResolver(`${mirrorUrl()}/kg/digital-objects.jsonld`, DigitalObjectsJsonLdSchema)( - route, - state, - ); - }, - asctbTerms: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const mirrorUrl = injectMirrorUrl(); - return createJsonSpecResolver(`${mirrorUrl()}/kg/asctb-terms.json`, AsctbTermsSchema)(route, state); - }, - termsIndex: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const mirrorUrl = injectMirrorUrl(); - return createJsonSpecResolver(`${mirrorUrl()}/kg/kg-terms-index.json`, TermsIndexSchema)(route, state); - }, + data: kgJsonResolver('/kg/digital-objects.jsonld', DigitalObjectsJsonLdSchema), + asctbTerms: kgJsonResolver('/kg/asctb-terms.json', AsctbTermsSchema), + termsIndex: kgJsonResolver('/kg/kg-terms-index.json', TermsIndexSchema), }, }, { @@ -129,17 +120,8 @@ export const appRoutes: Route[] = [ columns: METADATA_COLUMNS, }, resolve: { - doData: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const mirrorUrl = injectMirrorUrl(); - return createJsonSpecResolver(`${mirrorUrl()}/kg/digital-objects.jsonld`, DigitalObjectsJsonLdSchema)( - route, - state, - ); - }, - asctbTerms: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const mirrorUrl = injectMirrorUrl(); - return createJsonSpecResolver(`${mirrorUrl()}/kg/asctb-terms.json`, AsctbTermsSchema)(route, state); - }, + doData: kgJsonResolver('/kg/digital-objects.jsonld', DigitalObjectsJsonLdSchema), + asctbTerms: kgJsonResolver('/kg/asctb-terms.json', AsctbTermsSchema), metadata: doMetadataResolver(), documentationUrl: documentationUrlResolver(), typeLabel: productLabelResolver(), diff --git a/apps/kg-explorer/src/app/utils/kg-resolver.ts b/apps/kg-explorer/src/app/utils/kg-resolver.ts index 4a26574b7e..f7c034d517 100644 --- a/apps/kg-explorer/src/app/utils/kg-resolver.ts +++ b/apps/kg-explorer/src/app/utils/kg-resolver.ts @@ -1,17 +1,32 @@ import { HttpClient } from '@angular/common/http'; import { inject } from '@angular/core'; -import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; +import { ResolveFn } from '@angular/router'; +import { createJsonSpecResolver } from '@hra-ui/design-system/content-templates/resolvers'; import { catchError, map, of } from 'rxjs'; +import * as z from 'zod'; import { DigitalObjectMetadata } from '../digital-objects-metadata.schema'; import { injectMirrorUrl } from './endpoints'; import { getDocumentationUrl, getProductLabel } from './utils'; +/** + * Creates a resolver for fetching and validating JSON data from the given url using the provided zod schema + * @param url URL to fetch data from + * @param spec Zod schema to validate the data against + * @returns Resolver function that can be used in Angular routes + */ +export function kgJsonResolver(url: string, spec: T): ResolveFn> { + return (route, state) => { + const mirrorUrl = injectMirrorUrl(); + return createJsonSpecResolver(`${mirrorUrl()}${url}`, spec)(route, state); + }; +} + /** * Creates a resolver for digital object metadata from the current route - * @returns Resolver + * @returns Resolver function for digital object metadata */ export function doMetadataResolver(): ResolveFn { - return (route: ActivatedRouteSnapshot) => { + return (route) => { const type = route.paramMap.get('type') || ''; const name = route.paramMap.get('name') || ''; const version = route.paramMap.get('version') || ''; @@ -29,7 +44,7 @@ export function doMetadataResolver(): ResolveFn { * @returns url resolver */ export function documentationUrlResolver(): ResolveFn { - return (route: ActivatedRouteSnapshot) => { + return (route) => { const type = route.params['type']; return getDocumentationUrl(type); }; @@ -40,7 +55,7 @@ export function documentationUrlResolver(): ResolveFn { * @returns product label resolver */ export function productLabelResolver(): ResolveFn { - return (route: ActivatedRouteSnapshot) => { + return (route) => { const type = route.params['type']; return getProductLabel(type); }; From 581467482af0f272f9556e399be090aa32e7266b Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 22 Apr 2026 12:32:24 -0400 Subject: [PATCH 10/19] Rename handleValue function and have it always return an array --- .../metadata-page/metadata-page.component.ts | 4 ++-- .../src/app/services/search.service.ts | 6 +++--- .../src/app/state/with-filters.feature.ts | 16 +++++++--------- apps/kg-explorer/src/app/utils/utils.ts | 18 +++++++++++------- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts index cb26e6508c..98ba950e06 100644 --- a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts +++ b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts @@ -21,7 +21,7 @@ import { PersonInfo, } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; -import { getOrganIcon, getProductIcon, getProductLabel, handleValue, sentenceCase } from '../../utils/utils'; +import { getOrganIcon, getProductIcon, getProductLabel, coerceArray, sentenceCase } from '../../utils/utils'; /** * Metadata page for a digital object @@ -152,7 +152,7 @@ export class MetadataPageComponent { } const tags = [{ id: type, label: getProductLabel(type), type: 'do' }]; if (pageItem.organIds) { - const ids = handleValue(pageItem.organIds) || []; + const ids = coerceArray(pageItem.organIds); for (const organId of ids) { tags.push({ id: organId, diff --git a/apps/kg-explorer/src/app/services/search.service.ts b/apps/kg-explorer/src/app/services/search.service.ts index 1368303d22..9f94178d08 100644 --- a/apps/kg-explorer/src/app/services/search.service.ts +++ b/apps/kg-explorer/src/app/services/search.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { TableRow } from '@hra-ui/design-system/table'; import { from, Observable } from 'rxjs'; import { TermsIndex } from '../digital-objects-metadata.schema'; -import { handleValue } from '../utils/utils'; +import { coerceArray } from '../utils/utils'; @Injectable({ providedIn: 'root', @@ -33,11 +33,11 @@ export class SearchService { const filteredByVersions = filteredByDigitalObjects .filter((row) => { - const rowVersions = handleValue(row['hraVersions'] as string[] | string | undefined); + const rowVersions = coerceArray(row['hraVersions'] as string[] | string | undefined); if (versions.length === 0) { return true; } - if (rowVersions) { + if (rowVersions.length > 0) { return rowVersions?.some((version) => versions.includes(version)); } return false; diff --git a/apps/kg-explorer/src/app/state/with-filters.feature.ts b/apps/kg-explorer/src/app/state/with-filters.feature.ts index 7977089242..18c98f4f37 100644 --- a/apps/kg-explorer/src/app/state/with-filters.feature.ts +++ b/apps/kg-explorer/src/app/state/with-filters.feature.ts @@ -4,13 +4,13 @@ import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods import { FilterFormValues } from '../components/filter-menu/filter-menu.component'; import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; import { + coerceArray, FilterOption, formatDateToYYYYMM, getOrganIcon, getProductIcon, getProductLabel, getProductTooltip, - handleValue, HRA_VERSION_DATA, sentenceCase, } from '../utils/utils'; @@ -38,7 +38,7 @@ function resolveData(data?: DigitalObjectInfo[]): TableRow[] { return []; } return data.map((item) => { - const organLabel = item.organs ? handleValue(item.organs)?.[0] : undefined; + const organLabel = item.organs ? coerceArray(item.organs)[0] : undefined; return { id: item.lod, purl: item.purl, @@ -77,11 +77,11 @@ function getVersionCounts(data: DigitalObjectInfo[]): Record { function calculateCount(filterOption: string, category: string, rows: TableRow[]): number { return rows.filter((row) => { - const cat = handleValue(row[category] as string[] | string | undefined); + const cat = coerceArray(row[category] as string[] | string | undefined); if (cat) { return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); } - return cat === filterOption; + return false; }).length; } @@ -139,12 +139,10 @@ export function withFilters() { const organFilterOptions = new Set(); allRows().forEach((row) => { const type = row['doType']; + const organs = coerceArray(row['organIds'] as string[] | string | undefined); objectFilterOptions.add(type as string); - const organs = handleValue(row['organIds'] as string[] | string | undefined); - if (organs) { - for (const organ of organs) { - organFilterOptions.add(organ); - } + for (const organ of organs) { + organFilterOptions.add(organ); } }); return { diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index a9236c5706..ffbcff6964 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -315,13 +315,13 @@ export const HRA_VERSION_DATA: Record = }; /** - * Gets organ id from a digital object. If more than one organ is listed return blank string + * Gets organ id from a digital object. If more than one organ is listed return undefined * @param item Digital object data item * @returns Organ id */ export function getOrganId(item?: DigitalObjectInfo): string | undefined { - const ids = handleValue(item?.organIds); - return ids && ids.length === 1 ? ids[0] : undefined; + const ids = coerceArray(item?.organIds); + return ids.length === 1 ? ids[0] : undefined; } /** @@ -382,11 +382,15 @@ export function sentenceCase(value: string): string { return processedValue.charAt(0).toUpperCase() + processedValue.slice(1); } -export function handleValue(value: string | string[] | undefined): string[] | undefined { - if (typeof value === 'string') { - return [value]; +export function coerceArray(value: string | string[] | undefined): string[] { + switch (typeof value) { + case 'undefined': + return []; + case 'string': + return [value]; + default: + return value; } - return value; } /** From 10657aac69b9c1d2b2c908ad3447e3b44a1a631b Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 22 Apr 2026 13:50:30 -0400 Subject: [PATCH 11/19] Refactor search service --- .../pages/main-page/main-page.component.ts | 2 +- .../src/app/services/search.service.ts | 121 +++++++++--------- 2 files changed, 63 insertions(+), 60 deletions(-) diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index 178221c8a4..7a527ec1e1 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -217,7 +217,7 @@ export class MainPageComponent { * @returns object search */ private digitalObjectSearch(): Observable { - return this.search.doSearch(this.store.allRows(), this.store.termsIndex(), { + return this.search.search(this.store.allRows(), this.store.termsIndex(), { digitalObjects: this.store.digitalObjects() ?? [], versions: this.store.releaseVersion() ?? [], organs: this.store.organs() ?? [], diff --git a/apps/kg-explorer/src/app/services/search.service.ts b/apps/kg-explorer/src/app/services/search.service.ts index 9f94178d08..13741be3d1 100644 --- a/apps/kg-explorer/src/app/services/search.service.ts +++ b/apps/kg-explorer/src/app/services/search.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { TableRow } from '@hra-ui/design-system/table'; -import { from, Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { TermsIndex } from '../digital-objects-metadata.schema'; import { coerceArray } from '../utils/utils'; @@ -8,77 +8,39 @@ import { coerceArray } from '../utils/utils'; providedIn: 'root', }) export class SearchService { - doSearch( + search( rows: TableRow[], termsIndex: TermsIndex, options: { - organs: string[]; + searchTerm: string | null; + digitalObjects: string[]; versions: string[]; + organs: string[]; ontologyTerms: string[]; cellTypeTerms: string[]; biomarkerTerms: string[]; - digitalObjects: string[]; - searchTerm: string | null; }, ): Observable { const { organs, versions, ontologyTerms, cellTypeTerms, biomarkerTerms, searchTerm, digitalObjects } = options; + const filters = [ + this.createSearchFilter(searchTerm), + this.createDigitalObjectFilter(digitalObjects), + this.createVersionFilter(versions), + ]; + const asctbFilters = [ + this.createAsctbFilter(organs, termsIndex), + this.createAsctbFilter(ontologyTerms, termsIndex), + this.createAsctbFilter(cellTypeTerms, termsIndex), + this.createAsctbFilter(biomarkerTerms, termsIndex), + ]; - const filteredByDigitalObjects = rows.filter((row) => { - const type = row['doType'] as string; - if (digitalObjects.length === 0) { - return true; - } - return digitalObjects.includes(type); - }); - - const filteredByVersions = filteredByDigitalObjects - .filter((row) => { - const rowVersions = coerceArray(row['hraVersions'] as string[] | string | undefined); - if (versions.length === 0) { - return true; - } - if (rowVersions.length > 0) { - return rowVersions?.some((version) => versions.includes(version)); - } - return false; - }) - .map((row) => row['purl'] as string); - - const filteredBySearchTerm = filteredByVersions.filter((entry) => { - return entry.toLowerCase().includes((searchTerm || '').toLowerCase()); - }); - - const filteredByOrgans = filteredBySearchTerm.filter((term) => { - if (organs.length === 0) { - return true; - } - return this.getPurlsFromTerms(organs, termsIndex).has(term); - }); - - const filteredByOntologyTerms = filteredByOrgans.filter((term) => { - if (ontologyTerms.length === 0) { - return true; - } - return this.getPurlsFromTerms(ontologyTerms, termsIndex).has(term); - }); - const filteredByCellTypeTerms = filteredByOntologyTerms.filter((term) => { - if (cellTypeTerms.length === 0) { - return true; - } - return this.getPurlsFromTerms(cellTypeTerms, termsIndex).has(term); - }); - - const filteredByBiomarkerTerms = filteredByCellTypeTerms.filter((term) => { - if (biomarkerTerms.length === 0) { - return true; - } - return this.getPurlsFromTerms(biomarkerTerms, termsIndex).has(term); - }); - - return from([filteredByBiomarkerTerms]); + const filteredRows = rows.filter((item) => filters.every((fn) => fn(item))); + const filteredPurls = filteredRows.map((item) => item['purl'] as string); + const result = filteredPurls.filter((purl) => asctbFilters.every((fn) => fn(purl))); + return of(result); } - getPurlsFromTerms(terms: string[], termsIndex: TermsIndex): Set { + private getPurlsFromTerms(terms: string[], termsIndex: TermsIndex): Set { const purls = new Set(); terms.forEach((term) => { const purlIndexes = termsIndex.term_to_purls[termsIndex.terms.indexOf(term)]; @@ -88,4 +50,45 @@ export class SearchService { }); return purls; } + + private createSearchFilter(searchTerm: string | null): (item: TableRow) => boolean { + if (!searchTerm || searchTerm === '') { + return () => true; + } + return (item) => { + const title = (item['title'] as string).toLowerCase(); + return title.includes(searchTerm.toLowerCase()); + }; + } + + private createDigitalObjectFilter(options: string[]): (item: TableRow) => boolean { + const ids = new Set(options); + if (ids.size === 0) { + return () => true; + } + return (item) => ids.has(item['doType'] as string); + } + private createVersionFilter(options: string[]): (item: TableRow) => boolean { + const ids = new Set(options); + if (ids.size === 0) { + return () => true; + } + return (item) => { + const rowVersions = coerceArray(item['hraVersions'] as string[] | string | undefined); + if (rowVersions.length > 0) { + return rowVersions?.some((version) => ids.has(version)); + } + return false; + }; + } + private createAsctbFilter(options: string[], termsIndex: TermsIndex): (item: string) => boolean { + const ids = new Set(options); + if (ids.size === 0) { + return () => true; + } + const purls = this.getPurlsFromTerms(coerceArray(options), termsIndex); + return (item) => { + return purls.has(item); + }; + } } From 6d85cfb4b5923437ca868fecdc872d43e6727aab Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 22 Apr 2026 15:36:35 -0400 Subject: [PATCH 12/19] Add helper functions for metadata page --- .../metadata-page/metadata-page.component.ts | 134 ++++++++++-------- 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts index 98ba950e06..262e1a1d81 100644 --- a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts +++ b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts @@ -1,7 +1,6 @@ import '@google/model-viewer'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, effect, inject, input, signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { Component, computed, CUSTOM_ELEMENTS_SCHEMA, effect, inject, input, signal } from '@angular/core'; import { MatChipsModule } from '@angular/material/chips'; import { ActivatedRoute, Router } from '@angular/router'; import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; @@ -16,12 +15,13 @@ import { MetadataLayoutModule } from '../../components/metadata-layout/metadata- import { ProvenanceMenuComponent } from '../../components/provenance-menu/provenance-menu.component'; import { AsctbTerms, + DigitalObjectInfo, DigitalObjectMetadata, DigitalObjectsJsonLd, PersonInfo, } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; -import { getOrganIcon, getProductIcon, getProductLabel, coerceArray, sentenceCase } from '../../utils/utils'; +import { coerceArray, getOrganIcon, getProductIcon, getProductLabel, sentenceCase } from '../../utils/utils'; /** * Metadata page for a digital object @@ -59,6 +59,8 @@ export class MetadataPageComponent { /** Metadata for the digital object */ readonly metadata = input.required(); + readonly allItems = computed(() => this.doData()['@graph']); + /** Versions available for this digital object */ readonly availableVersions = signal([]); /** Current version selected */ @@ -98,71 +100,24 @@ export class MetadataPageComponent { this.router.navigate([type, name, this.currentVersion()]); }); - toObservable(this.metadata).subscribe((metadata) => { - if (metadata) { - this.downloadOptions.set(this.download.getDownloadOptions(metadata)); - this.rows.set( - [ - { provenance: 'Creator(s)', metadata: this.createMarkdownList(metadata.was_derived_from.creators) }, - { - provenance: 'Project lead(s)', - metadata: this.createMarkdownList(metadata.was_derived_from.project_leads), - }, - { - provenance: 'Internal reviewer(s)', - metadata: this.createMarkdownList(metadata.was_derived_from.reviewers), - }, - { - provenance: 'External reviewer(s)', - metadata: this.createMarkdownList(metadata.was_derived_from.externalReviewers), - }, - { - provenance: 'DOI', - metadata: metadata.was_derived_from.doi - ? `[${metadata.was_derived_from.doi}](${metadata.was_derived_from.doi})` - : '', - }, - { provenance: 'Data ID', metadata: metadata.was_derived_from.hubmapId ?? '' }, - { provenance: 'Date published', metadata: metadata.was_derived_from.creation_date ?? '' }, - { provenance: 'Date last processed', metadata: metadata.creation_date ?? '' }, - ].filter((item) => item.metadata !== ''), - ); + effect(() => { + if (this.metadata()) { + this.setProvenanceData(); } else { this.router.navigate([`404`]); } }); - toObservable(this.doData).subscribe((data: DigitalObjectsJsonLd) => { - if (data['@graph']) { - const pageItem = data['@graph'].find((item) => { - return item['@id'] === `https://lod.humanatlas.io/${type}/${name}`; - }); - this.purl.set(pageItem?.purl || ''); - const icons = [getProductIcon(type)]; - if (pageItem?.organIds) { - icons.push(getOrganIcon(pageItem)); - } - this.icons.set(icons); - - if (pageItem) { - if (Array.isArray(pageItem.versions)) { - this.availableVersions.set(pageItem.versions); - } else { - this.availableVersions.set([pageItem.versions]); - } - const tags = [{ id: type, label: getProductLabel(type), type: 'do' }]; - if (pageItem.organIds) { - const ids = coerceArray(pageItem.organIds); - for (const organId of ids) { - tags.push({ - id: organId, - label: sentenceCase(this.asctbTerms().find((term) => term.iri === organId)?.label || ''), - type: 'organs', - }); - } - } - this.tags.set(tags); - } + effect(() => { + const pageItem = this.allItems().find((item) => { + return item['@id'] === `https://lod.humanatlas.io/${type}/${name}`; + }) as DigitalObjectInfo; + + if (pageItem) { + this.purl.set(pageItem.purl || ''); + this.setIcons(pageItem, type); + this.availableVersions.set(coerceArray(pageItem.versions).sort((a, b) => b.localeCompare(a))); + this.setTags(pageItem, type); } }); } @@ -222,4 +177,57 @@ export class MetadataPageComponent { tagClick(id: string, type: string) { this.router.navigate([''], { queryParams: { [type]: id } }); } + + private setProvenanceData() { + this.downloadOptions.set(this.download.getDownloadOptions(this.metadata())); + this.rows.set( + [ + { provenance: 'Creator(s)', metadata: this.createMarkdownList(this.metadata().was_derived_from.creators) }, + { + provenance: 'Project lead(s)', + metadata: this.createMarkdownList(this.metadata().was_derived_from.project_leads), + }, + { + provenance: 'Internal reviewer(s)', + metadata: this.createMarkdownList(this.metadata().was_derived_from.reviewers), + }, + { + provenance: 'External reviewer(s)', + metadata: this.createMarkdownList(this.metadata().was_derived_from.externalReviewers), + }, + { + provenance: 'DOI', + metadata: this.metadata().was_derived_from.doi + ? `[${this.metadata().was_derived_from.doi}](${this.metadata().was_derived_from.doi})` + : '', + }, + { provenance: 'Data ID', metadata: this.metadata().was_derived_from.hubmapId ?? '' }, + { provenance: 'Date published', metadata: this.metadata().was_derived_from.creation_date ?? '' }, + { provenance: 'Date last processed', metadata: this.metadata().creation_date ?? '' }, + ].filter((item) => item.metadata !== ''), + ); + } + + private setIcons(item: DigitalObjectInfo, type: string) { + const icons = [getProductIcon(type)]; + if (item.organIds) { + icons.push(getOrganIcon(item)); + } + this.icons.set(icons); + } + + private setTags(item: DigitalObjectInfo, type: string) { + const tags = [{ id: type, label: getProductLabel(type), type: 'do' }]; + if (item.organIds) { + const ids = coerceArray(item.organIds); + for (const organId of ids) { + tags.push({ + id: organId, + label: sentenceCase(this.asctbTerms().find((term) => term.iri === organId)?.label || ''), + type: 'organs', + }); + } + } + this.tags.set(tags); + } } From 9381c1f8081a2a53f86f99db48eb7958f58ca262 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 22 Apr 2026 17:20:02 -0400 Subject: [PATCH 13/19] Split store into two separate features --- .../pages/main-page/main-page.component.ts | 4 +- .../src/app/state/filters.store.ts | 3 +- .../src/app/state/with-do-data.feature.ts | 197 ++++++++++++++++++ .../src/app/state/with-filters.feature.ts | 176 ---------------- 4 files changed, 202 insertions(+), 178 deletions(-) create mode 100644 apps/kg-explorer/src/app/state/with-do-data.feature.ts diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index 7a527ec1e1..636beeec6a 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -109,7 +109,9 @@ export class MainPageComponent { constructor() { const queryParams$ = inject(ActivatedRoute).queryParams; queryParams$.subscribe((queryParams) => this.setFiltersFromQueryParams(queryParams)); - this.store.setData(this.data); + + this.store.setAllRows(this.data); + this.store.setVersionCounts(this.data); this.store.setAsctbTerms(this.asctbTerms); this.store.setTermsIndex(this.termsIndex); diff --git a/apps/kg-explorer/src/app/state/filters.store.ts b/apps/kg-explorer/src/app/state/filters.store.ts index 75e64c23f6..010f82f30c 100644 --- a/apps/kg-explorer/src/app/state/filters.store.ts +++ b/apps/kg-explorer/src/app/state/filters.store.ts @@ -1,4 +1,5 @@ import { signalStore } from '@ngrx/signals'; +import { withDigitalObjectsData } from './with-do-data.feature'; import { withFilters } from './with-filters.feature'; -export const FiltersStore = signalStore({ providedIn: 'root' }, withFilters()); +export const FiltersStore = signalStore({ providedIn: 'root' }, withDigitalObjectsData(), withFilters()); diff --git a/apps/kg-explorer/src/app/state/with-do-data.feature.ts b/apps/kg-explorer/src/app/state/with-do-data.feature.ts new file mode 100644 index 0000000000..8b88f0444d --- /dev/null +++ b/apps/kg-explorer/src/app/state/with-do-data.feature.ts @@ -0,0 +1,197 @@ +import { computed } from '@angular/core'; +import { TableRow } from '@hra-ui/design-system/table'; +import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals'; +import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; +import { + coerceArray, + FilterOption, + formatDateToYYYYMM, + getOrganIcon, + getProductIcon, + getProductLabel, + getProductTooltip, + HRA_VERSION_DATA, + sentenceCase, +} from '../utils/utils'; + +export interface DigitalObjectsDataState { + data: DigitalObjectsJsonLd; + asctbTerms: AsctbTerms; + termsIndex: TermsIndex; + allRows: TableRow[]; + versionCounts: Record; +} + +/** + * Resolves raw digital object data into array of TableRow + * @param data Raw digital object data + * @returns Data as TableRow[] + */ +function resolveData(data?: DigitalObjectInfo[]): TableRow[] { + if (!data) { + return []; + } + return data.map((item) => { + const organLabel = item.organs ? coerceArray(item.organs)[0] : undefined; + return { + id: item.lod, + purl: item.purl, + doType: item.doType, + hraVersions: item.hraVersions, + doVersion: item.doVersion, + organIds: item.organIds, + title: item.title, + objectUrl: `${item.doType}/${item.doName}/latest`, + typeIcon: getProductIcon(item.doType), + typeTooltip: getProductLabel(item.doType), + organIcon: getOrganIcon(item), + organTooltip: sentenceCase(organLabel || 'All Organs'), + cellCount: item.cell_count, + biomarkerCount: item.biomarker_count, + lastPublished: formatDateToYYYYMM(item.lastUpdated['@value']), + } as TableRow; + }); +} + +function getVersionCounts(data: DigitalObjectInfo[]): Record { + const result: Record = {}; + const allVersions = data.map((object) => object.hraVersions); + const flatVersions = allVersions.flat(); + for (const version of flatVersions) { + if (version) { + if (result[version]) { + result[version] += 1; + } else { + result[version] = 1; + } + } + } + return result; +} + +function calculateCount(filterOption: string, category: string, rows: TableRow[]): number { + return rows.filter((row) => { + const cat = coerceArray(row[category] as string[] | string | undefined); + if (cat) { + return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); + } + return false; + }).length; +} + +function generateAsctbOptions(asctbType: string, objects: AsctbTerms, termsIndex: TermsIndex): FilterOption[] { + return objects + .filter((term) => term.asctb_type === asctbType) + .map((term) => { + return { + id: term.iri, + label: term.label, + count: termsIndex.term_to_purls[termsIndex.terms.indexOf(term.iri)]?.length ?? 0, + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)); +} + +const initialState: DigitalObjectsDataState = { + data: { '@context': {}, '@graph': [] }, + asctbTerms: [], + termsIndex: { terms: [], purls: [], term_to_purls: [], purl_to_terms: [] }, + allRows: [], + versionCounts: {}, +}; + +export function withDigitalObjectsData() { + return signalStoreFeature( + withState(initialState), + withComputed((store) => { + const kgFilterOptions = computed(() => { + const objectFilterOptions = new Set(); + const organFilterOptions = new Set(); + store.allRows().forEach((row) => { + const doType = row['doType']; + const organs = coerceArray(row['organIds'] as string[] | string | undefined); + objectFilterOptions.add(doType as string); + for (const organ of organs) { + organFilterOptions.add(organ); + } + }); + return { + doOptions: objectFilterOptions, + organOptions: organFilterOptions, + }; + }); + + const _digitalObjectsOptions = computed(() => { + return Array.from(kgFilterOptions().doOptions) + .map((filterOption) => { + return { + id: filterOption, + label: getProductLabel(filterOption), + count: calculateCount(filterOption, 'doType', store.allRows()), + tooltip: getProductTooltip(filterOption), + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)) as FilterOption[]; + }); + + const _hraVersionsOptions = computed(() => { + return Object.keys(HRA_VERSION_DATA) + .map((filterOption) => { + const versionData = HRA_VERSION_DATA[filterOption]; + return { + id: filterOption, + label: versionData ? versionData.label : filterOption, + count: store.versionCounts()[filterOption], + secondaryLabel: versionData ? versionData.date : undefined, + }; + }) + .sort((o1, o2) => o2.id.localeCompare(o1.id)) as FilterOption[]; //Reverse order + }); + + const _organOptions = computed(() => { + return Array.from(kgFilterOptions().organOptions) + .map((organOption) => { + return { + id: organOption, + label: sentenceCase(store.asctbTerms().find((term) => term.iri === organOption)?.label ?? organOption), + count: calculateCount(organOption, 'organIds', store.allRows()), + }; + }) + .sort((o1, o2) => o1.label.localeCompare(o2.label)) as FilterOption[]; + }); + + const _anatomicalStructuresOptions = computed(() => + generateAsctbOptions('AS', store.asctbTerms(), store.termsIndex()), + ); + const _cellTypesOptions = computed(() => generateAsctbOptions('CT', store.asctbTerms(), store.termsIndex())); + const _biomarkerOptions = computed(() => generateAsctbOptions('BM', store.asctbTerms(), store.termsIndex())); + + const allFilters = computed(() => { + return { + digitalObjects: _digitalObjectsOptions(), + releaseVersion: _hraVersionsOptions(), + organs: _organOptions(), + anatomicalStructures: _anatomicalStructuresOptions(), + cellTypes: _cellTypesOptions(), + biomarkers: _biomarkerOptions(), + }; + }); + + return { + allFilters, + }; + }), + withMethods((store) => { + const setAllRows = signalMethod((data: DigitalObjectsJsonLd) => + patchState(store, { allRows: resolveData(data['@graph'] as DigitalObjectInfo[]) }), + ); + const setVersionCounts = signalMethod((data: DigitalObjectsJsonLd) => + patchState(store, { versionCounts: getVersionCounts(data['@graph'] as DigitalObjectInfo[]) }), + ); + const setAsctbTerms = signalMethod((asctbTerms: AsctbTerms) => patchState(store, { asctbTerms })); + const setTermsIndex = signalMethod((termsIndex: TermsIndex) => patchState(store, { termsIndex })); + + return { setAllRows, setVersionCounts, setAsctbTerms, setTermsIndex }; + }), + ); +} diff --git a/apps/kg-explorer/src/app/state/with-filters.feature.ts b/apps/kg-explorer/src/app/state/with-filters.feature.ts index 18c98f4f37..f09e7e2182 100644 --- a/apps/kg-explorer/src/app/state/with-filters.feature.ts +++ b/apps/kg-explorer/src/app/state/with-filters.feature.ts @@ -1,24 +1,8 @@ import { computed } from '@angular/core'; -import { TableRow } from '@hra-ui/design-system/table'; import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals'; import { FilterFormValues } from '../components/filter-menu/filter-menu.component'; -import { AsctbTerms, DigitalObjectInfo, DigitalObjectsJsonLd, TermsIndex } from '../digital-objects-metadata.schema'; -import { - coerceArray, - FilterOption, - formatDateToYYYYMM, - getOrganIcon, - getProductIcon, - getProductLabel, - getProductTooltip, - HRA_VERSION_DATA, - sentenceCase, -} from '../utils/utils'; export interface FiltersState { - data: DigitalObjectsJsonLd; - asctbTerms: AsctbTerms; - termsIndex: TermsIndex; digitalObjects: string[] | null; releaseVersion: string[] | null; organs: string[] | null; @@ -28,81 +12,8 @@ export interface FiltersState { searchTerm: string | null; } -/** - * Resolves raw digital object data into array of TableRow - * @param data Raw digital object data - * @returns Data as TableRow[] - */ -function resolveData(data?: DigitalObjectInfo[]): TableRow[] { - if (!data) { - return []; - } - return data.map((item) => { - const organLabel = item.organs ? coerceArray(item.organs)[0] : undefined; - return { - id: item.lod, - purl: item.purl, - doType: item.doType, - hraVersions: item.hraVersions, - doVersion: item.doVersion, - organIds: item.organIds, - title: item.title, - objectUrl: `${item.doType}/${item.doName}/latest`, - typeIcon: getProductIcon(item.doType), - typeTooltip: getProductLabel(item.doType), - organIcon: getOrganIcon(item), - organTooltip: sentenceCase(organLabel || 'All Organs'), - cellCount: item.cell_count, - biomarkerCount: item.biomarker_count, - lastPublished: formatDateToYYYYMM(item.lastUpdated['@value']), - } as TableRow; - }); -} - -function getVersionCounts(data: DigitalObjectInfo[]): Record { - const result: Record = {}; - const allVersions = data.map((object) => object.hraVersions); - const flatVersions = allVersions.flat(); - for (const version of flatVersions) { - if (version) { - if (result[version]) { - result[version] += 1; - } else { - result[version] = 1; - } - } - } - return result; -} - -function calculateCount(filterOption: string, category: string, rows: TableRow[]): number { - return rows.filter((row) => { - const cat = coerceArray(row[category] as string[] | string | undefined); - if (cat) { - return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); - } - return false; - }).length; -} - -function generateAsctbOptions(type: string, objects: AsctbTerms, termsIndex: TermsIndex): FilterOption[] { - return objects - .filter((term) => term.asctb_type === type) - .map((term) => { - return { - id: term.iri, - label: term.label, - count: termsIndex.term_to_purls[termsIndex.terms.indexOf(term.iri)]?.length ?? 0, - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)); -} - /** Initial state for the filters store */ const initialState: FiltersState = { - data: { '@context': {}, '@graph': [] }, - asctbTerms: [], - termsIndex: { terms: [], purls: [], term_to_purls: [], purl_to_terms: [] }, digitalObjects: null, releaseVersion: null, organs: null, @@ -126,90 +37,7 @@ export function withFilters() { searchTerm: store.searchTerm(), })); - const allRows = computed(() => { - return resolveData(store.data()['@graph'] as DigitalObjectInfo[]); - }); - - const versionCounts = computed(() => { - return getVersionCounts(store.data()['@graph'] as DigitalObjectInfo[]); - }); - - const kgFilterOptions = computed(() => { - const objectFilterOptions = new Set(); - const organFilterOptions = new Set(); - allRows().forEach((row) => { - const type = row['doType']; - const organs = coerceArray(row['organIds'] as string[] | string | undefined); - objectFilterOptions.add(type as string); - for (const organ of organs) { - organFilterOptions.add(organ); - } - }); - return { - doOptions: objectFilterOptions, - organOptions: organFilterOptions, - }; - }); - - const _digitalObjectsOptions = computed(() => { - return Array.from(kgFilterOptions().doOptions) - .map((filterOption) => { - return { - id: filterOption, - label: getProductLabel(filterOption), - count: calculateCount(filterOption, 'doType', allRows()), - tooltip: getProductTooltip(filterOption), - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)) as FilterOption[]; - }); - - const _hraVersionsOptions = computed(() => { - return Object.keys(HRA_VERSION_DATA) - .map((filterOption) => { - const versionData = HRA_VERSION_DATA[filterOption]; - return { - id: filterOption, - label: versionData ? versionData.label : filterOption, - count: versionCounts()[filterOption], - secondaryLabel: versionData ? versionData.date : undefined, - }; - }) - .sort((o1, o2) => o2.id.localeCompare(o1.id)) as FilterOption[]; //Reverse order - }); - - const _organOptions = computed(() => { - return Array.from(kgFilterOptions().organOptions) - .map((organOption) => { - return { - id: organOption, - label: sentenceCase(store.asctbTerms().find((term) => term.iri === organOption)?.label ?? organOption), - count: calculateCount(organOption, 'organIds', allRows()), - }; - }) - .sort((o1, o2) => o1.label.localeCompare(o2.label)) as FilterOption[]; - }); - - const _anatomicalStructuresOptions = computed(() => - generateAsctbOptions('AS', store.asctbTerms(), store.termsIndex()), - ); - const _cellTypesOptions = computed(() => generateAsctbOptions('CT', store.asctbTerms(), store.termsIndex())); - const _biomarkerOptions = computed(() => generateAsctbOptions('BM', store.asctbTerms(), store.termsIndex())); - - const allFilters = computed(() => { - return { - digitalObjects: _digitalObjectsOptions(), - releaseVersion: _hraVersionsOptions(), - organs: _organOptions(), - anatomicalStructures: _anatomicalStructuresOptions(), - cellTypes: _cellTypesOptions(), - biomarkers: _biomarkerOptions(), - }; - }); - return { - allRows, - allFilters, currentFilters, }; }), @@ -224,10 +52,6 @@ export function withFilters() { setBiomarkers: signalMethod((biomarkers: string[]) => patchState(store, { biomarkers })), setSearchTerm: signalMethod((searchTerm: string | null) => patchState(store, { searchTerm })), - setData: signalMethod((data: DigitalObjectsJsonLd) => patchState(store, { data })), - setAsctbTerms: signalMethod((asctbTerms: AsctbTerms) => patchState(store, { asctbTerms })), - setTermsIndex: signalMethod((termsIndex: TermsIndex) => patchState(store, { termsIndex })), - updateFiltersFromForm: signalMethod((formValues: FilterFormValues) => { patchState(store, { digitalObjects: formValues.digitalObjects?.map((obj) => obj.id), From a9123ea09f9f6d276fe12850e8b22c174e58e8f0 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 22 Apr 2026 19:45:22 -0400 Subject: [PATCH 14/19] Use rxResource for search handling --- .../pages/main-page/main-page.component.ts | 63 +++++++++++++------ .../src/app/state/with-filters.feature.ts | 23 ++++--- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index 636beeec6a..0717ecd56f 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -13,8 +13,9 @@ import { IconsModule } from '@hra-ui/design-system/icons'; import { ResultsIndicatorComponent } from '@hra-ui/design-system/indicators/results-indicator'; import { NavigationModule } from '@hra-ui/design-system/navigation'; import { TableColumn, TableComponent, TableRow } from '@hra-ui/design-system/table'; -import { fromEvent, Observable } from 'rxjs'; +import { fromEvent } from 'rxjs'; +import { rxResource } from '@angular/core/rxjs-interop'; import { FilterFormValues, FilterMenuComponent } from '../../components/filter-menu/filter-menu.component'; import { AsctbTerms, @@ -97,6 +98,43 @@ export class MainPageComponent { /** Id of digital object to download */ readonly downloadId = signal(undefined); + readonly searchResults = rxResource({ + params: () => ({ + allRows: this.store.allRows(), + termsIndex: this.store.termsIndex(), + digitalObjects: this.store.digitalObjects(), + versions: this.store.releaseVersion(), + organs: this.store.organs(), + ontologyTerms: this.store.anatomicalStructures(), + cellTypeTerms: this.store.cellTypes(), + biomarkerTerms: this.store.biomarkers(), + searchTerm: this.store.searchTerm(), + }), + stream: (params) => { + const { + allRows, + termsIndex, + digitalObjects, + versions, + organs, + ontologyTerms, + cellTypeTerms, + biomarkerTerms, + searchTerm, + } = params.params; + + return this.search.search(allRows, termsIndex, { + digitalObjects: digitalObjects ?? [], + versions: versions ?? [], + organs: organs ?? [], + ontologyTerms: ontologyTerms ?? [], + cellTypeTerms: cellTypeTerms ?? [], + biomarkerTerms: biomarkerTerms ?? [], + searchTerm: searchTerm ?? null, + }); + }, + }); + /** * Sets the initial filters according to query params * Sets filtered rows to all rows on init @@ -127,10 +165,11 @@ export class MainPageComponent { }); effect(() => { - this.digitalObjectSearch().subscribe((results) => { - const newFilteredRows = this.store.allRows().filter((row) => results.includes(row['purl'] as string)); + const searchResults = this.searchResults.value(); + if (searchResults) { + const newFilteredRows = this.store.allRows().filter((row) => searchResults.includes(row['purl'] as string)); this.filteredRows.set(newFilteredRows); - }); + } }); this.setScrollViewportHeight(); @@ -214,22 +253,6 @@ export class MainPageComponent { } } - /** - * Performs KG DO search for selected ontology, cell type, biomarker, and HRA release version filters - * @returns object search - */ - private digitalObjectSearch(): Observable { - return this.search.search(this.store.allRows(), this.store.termsIndex(), { - digitalObjects: this.store.digitalObjects() ?? [], - versions: this.store.releaseVersion() ?? [], - organs: this.store.organs() ?? [], - ontologyTerms: this.store.anatomicalStructures() ?? [], - cellTypeTerms: this.store.cellTypes() ?? [], - biomarkerTerms: this.store.biomarkers() ?? [], - searchTerm: this.store.searchTerm(), - }); - } - /** * Returns table scrollbar viewport height * @returns viewport height diff --git a/apps/kg-explorer/src/app/state/with-filters.feature.ts b/apps/kg-explorer/src/app/state/with-filters.feature.ts index f09e7e2182..d0d1449c06 100644 --- a/apps/kg-explorer/src/app/state/with-filters.feature.ts +++ b/apps/kg-explorer/src/app/state/with-filters.feature.ts @@ -1,6 +1,7 @@ import { computed } from '@angular/core'; import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals'; import { FilterFormValues } from '../components/filter-menu/filter-menu.component'; +import { coerceArray } from '../utils/utils'; export interface FiltersState { digitalObjects: string[] | null; @@ -42,14 +43,22 @@ export function withFilters() { }; }), withMethods((store) => ({ - setDigitalObjects: signalMethod((digitalObjects: string[]) => patchState(store, { digitalObjects })), - setReleaseVersion: signalMethod((releaseVersion: string[]) => patchState(store, { releaseVersion })), - setOrgans: signalMethod((organs: string[]) => patchState(store, { organs })), - setAnatomicalStructures: signalMethod((anatomicalStructures: string[]) => - patchState(store, { anatomicalStructures }), + setDigitalObjects: signalMethod((digitalObjects: string | string[]) => + patchState(store, { digitalObjects: coerceArray(digitalObjects) }), + ), + setReleaseVersion: signalMethod((releaseVersion: string | string[]) => + patchState(store, { releaseVersion: coerceArray(releaseVersion) }), + ), + setOrgans: signalMethod((organs: string | string[]) => patchState(store, { organs: coerceArray(organs) })), + setAnatomicalStructures: signalMethod((anatomicalStructures: string | string[]) => + patchState(store, { anatomicalStructures: coerceArray(anatomicalStructures) }), + ), + setCellTypes: signalMethod((cellTypes: string | string[]) => + patchState(store, { cellTypes: coerceArray(cellTypes) }), + ), + setBiomarkers: signalMethod((biomarkers: string | string[]) => + patchState(store, { biomarkers: coerceArray(biomarkers) }), ), - setCellTypes: signalMethod((cellTypes: string[]) => patchState(store, { cellTypes })), - setBiomarkers: signalMethod((biomarkers: string[]) => patchState(store, { biomarkers })), setSearchTerm: signalMethod((searchTerm: string | null) => patchState(store, { searchTerm })), updateFiltersFromForm: signalMethod((formValues: FilterFormValues) => { From 6f38239270c05487edc55ab8e85e40c7b41385cc Mon Sep 17 00:00:00 2001 From: edlu77 Date: Thu, 23 Apr 2026 10:53:13 -0400 Subject: [PATCH 15/19] Filtering tweaks --- apps/kg-explorer/src/app/pages/main-page/main-page.component.ts | 2 +- apps/kg-explorer/src/app/state/with-do-data.feature.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index 0717ecd56f..d373df5f1d 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -149,6 +149,7 @@ export class MainPageComponent { queryParams$.subscribe((queryParams) => this.setFiltersFromQueryParams(queryParams)); this.store.setAllRows(this.data); + this.filteredRows.set(this.store.allRows()); this.store.setVersionCounts(this.data); this.store.setAsctbTerms(this.asctbTerms); this.store.setTermsIndex(this.termsIndex); @@ -159,7 +160,6 @@ export class MainPageComponent { }); effect(() => { - this.filteredRows.set(this.store.allRows()); this.populateFilterOptions(); this.attachDownloadOptions(); }); diff --git a/apps/kg-explorer/src/app/state/with-do-data.feature.ts b/apps/kg-explorer/src/app/state/with-do-data.feature.ts index 8b88f0444d..c4ded692da 100644 --- a/apps/kg-explorer/src/app/state/with-do-data.feature.ts +++ b/apps/kg-explorer/src/app/state/with-do-data.feature.ts @@ -73,7 +73,7 @@ function calculateCount(filterOption: string, category: string, rows: TableRow[] return rows.filter((row) => { const cat = coerceArray(row[category] as string[] | string | undefined); if (cat) { - return cat.some((value) => String(value).toLowerCase().includes(filterOption.toLowerCase())); + return cat.some((value) => String(value).toLowerCase() === filterOption.toLowerCase()); } return false; }).length; From 5b72f26be0fbc05e2d4fe27ea61a8b12e0b9f646 Mon Sep 17 00:00:00 2001 From: edlu77 Date: Fri, 24 Apr 2026 00:54:03 -0400 Subject: [PATCH 16/19] Updates for file changes Co-authored-by: Copilot --- apps/kg-explorer/src/app/app.component.ts | 17 ++++++++++------- .../src/app/digital-objects-metadata.schema.ts | 5 +---- .../metadata-page/metadata-page.component.ts | 11 +++++++++-- .../src/app/state/with-do-data.feature.ts | 2 +- apps/kg-explorer/src/app/utils/utils.ts | 4 ++++ .../src/environments/environment.staging.ts | 2 +- .../kg-explorer/src/environments/environment.ts | 2 +- 7 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/kg-explorer/src/app/app.component.ts b/apps/kg-explorer/src/app/app.component.ts index 59891e5660..f737844107 100644 --- a/apps/kg-explorer/src/app/app.component.ts +++ b/apps/kg-explorer/src/app/app.component.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDividerModule } from '@angular/material/divider'; import { MatMenuModule } from '@angular/material/menu'; @@ -70,8 +70,6 @@ export class AppComponent extends BaseApplicationComponent { private readonly http = inject(HttpClient); - readonly customMirror = input(); - readonly mirrorUrl = injectMirrorUrl(); /** Page title to display on the breadcrumbs */ @@ -111,7 +109,12 @@ export class AppComponent extends BaseApplicationComponent { readonly params = signal([]); /** Id of digital object computed from params */ - readonly objectId = computed(() => ['https://lod.humanatlas.io'].concat(this.params()).join('/')); + readonly objectId = computed(() => [this.baseUrl()].concat(this.params()).join('/')); + + readonly baseUrl = computed(() => { + const lod = 'https://lod.humanatlas.io'; + return this.mirrorUrl() === 'https://cdn.humanatlas.io/digital-objects' ? lod : this.mirrorUrl(); + }); /** Digital objects */ private readonly digitalObjects = signal({ '@context': {}, '@graph': [] }); @@ -127,9 +130,9 @@ export class AppComponent extends BaseApplicationComponent { if (apiEndpoint) { setRemoteApiEndpoint(apiEndpoint); } - const mirrorUrl = el.getAttribute('custom-mirror') || el.getAttribute('mirror-url'); - if (mirrorUrl) { - setMirrorUrl(mirrorUrl); + const customMirrorUrl = el.getAttribute('mirror-url'); + if (customMirrorUrl) { + setMirrorUrl(customMirrorUrl); } effect(() => { diff --git a/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts b/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts index e816a7dc45..4ae2d2f96a 100644 --- a/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts +++ b/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts @@ -95,10 +95,7 @@ export const DigitalObjectInfoSchema = z doType: z.string(), doName: z.string(), doVersion: z.string(), - lastUpdated: z.object({ - '@type': z.string(), - '@value': z.string(), - }), + lastUpdated: z.string(), hraVersions: z.union([z.string(), z.string().array()]).optional(), versions: z.union([z.string(), z.string().array()]), purl: z.string(), diff --git a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts index 262e1a1d81..ab389dbe9b 100644 --- a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts +++ b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts @@ -22,6 +22,7 @@ import { } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; import { coerceArray, getOrganIcon, getProductIcon, getProductLabel, sentenceCase } from '../../utils/utils'; +import { injectMirrorUrl } from '../../utils/endpoints'; /** * Metadata page for a digital object @@ -50,6 +51,8 @@ export class MetadataPageComponent { /** File download service */ private readonly download = inject(DownloadService); + readonly mirrorUrl = injectMirrorUrl(); + /** Raw digital object data from API */ readonly doData = input.required(); readonly asctbTerms = input.required(); @@ -61,6 +64,11 @@ export class MetadataPageComponent { readonly allItems = computed(() => this.doData()['@graph']); + readonly baseUrl = computed(() => { + const lod = 'https://lod.humanatlas.io'; + return this.mirrorUrl() === 'https://cdn.humanatlas.io/digital-objects' ? lod : this.mirrorUrl(); + }); + /** Versions available for this digital object */ readonly availableVersions = signal([]); /** Current version selected */ @@ -110,9 +118,8 @@ export class MetadataPageComponent { effect(() => { const pageItem = this.allItems().find((item) => { - return item['@id'] === `https://lod.humanatlas.io/${type}/${name}`; + return item['@id'] === `${this.baseUrl()}/${type}/${name}`; }) as DigitalObjectInfo; - if (pageItem) { this.purl.set(pageItem.purl || ''); this.setIcons(pageItem, type); diff --git a/apps/kg-explorer/src/app/state/with-do-data.feature.ts b/apps/kg-explorer/src/app/state/with-do-data.feature.ts index c4ded692da..b729365f1e 100644 --- a/apps/kg-explorer/src/app/state/with-do-data.feature.ts +++ b/apps/kg-explorer/src/app/state/with-do-data.feature.ts @@ -48,7 +48,7 @@ function resolveData(data?: DigitalObjectInfo[]): TableRow[] { organTooltip: sentenceCase(organLabel || 'All Organs'), cellCount: item.cell_count, biomarkerCount: item.biomarker_count, - lastPublished: formatDateToYYYYMM(item.lastUpdated['@value']), + lastPublished: formatDateToYYYYMM(item.lastUpdated), } as TableRow; }); } diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index ffbcff6964..6ca061ae94 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -272,6 +272,10 @@ export const ORGAN_ICON_MAP: Record = { /** HRA version data info */ export const HRA_VERSION_DATA: Record = { + 'v2.5': { + label: '11th Release (v2.5)', + date: 'June 2025', + }, 'v2.4': { label: '10th Release (v2.4)', date: 'December 2025', diff --git a/apps/kg-explorer/src/environments/environment.staging.ts b/apps/kg-explorer/src/environments/environment.staging.ts index f1cf7fb0c3..265fc21685 100644 --- a/apps/kg-explorer/src/environments/environment.staging.ts +++ b/apps/kg-explorer/src/environments/environment.staging.ts @@ -2,5 +2,5 @@ export const environment = { production: true, remoteApiEndpoint: 'https://apps.humanatlas.io/api--staging', - mirrorUrl: 'https://cdn.humanatlas.io/digital-objects', + mirrorUrl: 'https://cdn.humanatlas.io/hra-kg--staging', }; diff --git a/apps/kg-explorer/src/environments/environment.ts b/apps/kg-explorer/src/environments/environment.ts index 960af2668f..a008bf2c8e 100644 --- a/apps/kg-explorer/src/environments/environment.ts +++ b/apps/kg-explorer/src/environments/environment.ts @@ -2,5 +2,5 @@ export const environment = { production: false, remoteApiEndpoint: 'https://apps.humanatlas.io/api--staging', - mirrorUrl: 'https://cdn.humanatlas.io/digital-objects', + mirrorUrl: 'https://cdn.humanatlas.io/hra-kg--staging', }; From c517194001047a90b53ad68435ae2ef84555a5cc Mon Sep 17 00:00:00 2001 From: edlu77 Date: Fri, 24 Apr 2026 18:32:16 -0400 Subject: [PATCH 17/19] Updates for 3d FTU --- .../pages/main-page/main-page.component.ts | 27 ++++++++++++++----- .../metadata-page/metadata-page.component.ts | 3 ++- apps/kg-explorer/src/app/utils/utils.ts | 11 ++++++++ .../assets/icons/product/3d-ftu.svg | 9 +++++++ 4 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 libs/design-system/assets/icons/product/3d-ftu.svg diff --git a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts index d373df5f1d..6b9f749b13 100644 --- a/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts +++ b/apps/kg-explorer/src/app/pages/main-page/main-page.component.ts @@ -13,7 +13,7 @@ import { IconsModule } from '@hra-ui/design-system/icons'; import { ResultsIndicatorComponent } from '@hra-ui/design-system/indicators/results-indicator'; import { NavigationModule } from '@hra-ui/design-system/navigation'; import { TableColumn, TableComponent, TableRow } from '@hra-ui/design-system/table'; -import { fromEvent } from 'rxjs'; +import { catchError, fromEvent, of } from 'rxjs'; import { rxResource } from '@angular/core/rxjs-interop'; import { FilterFormValues, FilterMenuComponent } from '../../components/filter-menu/filter-menu.component'; @@ -243,14 +243,29 @@ export class MainPageComponent { * Makes metadata request for the object matching downloadId and attaches download options to the row */ private attachDownloadOptions() { - if (this.downloadId()) { - this.http.get(this.downloadId() || '', { responseType: 'json' }).subscribe((data) => { - const match = this.store.allRows().find((row) => row['id'] === this.downloadId()); - if (match) { + const downloadId = this.downloadId(); + if (!downloadId) { + return; + } + + this.http + .get(this.getMetadataUrl(downloadId), { responseType: 'json' }) + .pipe(catchError(() => of(undefined))) + .subscribe((data) => { + const match = this.store.allRows().find((row) => row['id'] === downloadId); + if (match && data) { match['downloadOptions'] = this.download.getDownloadOptions(data as DigitalObjectMetadata); } }); - } + } + + /** + * Returns the metadata endpoint for a digital object id. + * @param downloadId Digital object dataset id + * @returns Metadata url + */ + private getMetadataUrl(downloadId: string): string { + return `${downloadId.replace(/\/$/, '')}/metadata.json`; } /** diff --git a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts index ab389dbe9b..09df351d45 100644 --- a/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts +++ b/apps/kg-explorer/src/app/pages/metadata-page/metadata-page.component.ts @@ -21,8 +21,8 @@ import { PersonInfo, } from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; -import { coerceArray, getOrganIcon, getProductIcon, getProductLabel, sentenceCase } from '../../utils/utils'; import { injectMirrorUrl } from '../../utils/endpoints'; +import { coerceArray, getOrganIcon, getProductIcon, getProductLabel, sentenceCase } from '../../utils/utils'; /** * Metadata page for a digital object @@ -90,6 +90,7 @@ export class MetadataPageComponent { /** For these DoTypes the corresponding image types will be displayed on the page */ readonly imageTypes: Record = { '2d-ftu': 'image/svg+xml', + '3d-ftu': 'model/gltf-binary', 'ref-organ': 'model/gltf-binary', landmark: 'model/gltf-binary', schema: 'image/svg+xml', diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index 6ca061ae94..b89daa74dc 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -171,6 +171,17 @@ export const DO_INFO: Record = { icon: 'ftu', documentationUrl: 'https://humanatlas.io/2d-ftu-illustrations', }, + '3d-ftu': { + label: '3D Functional Tissue Unit Illustrations', + tooltip: { + description: + 'A functional tissue unit is the smallest tissue organization, i.e. a set of cells, that performs a unique physiologic function and is replicated multiple times in a whole organ. Functional Tissue Unit (FTU) Illustrations are linked to ASCT+B Tables.', + actionText: 'Learn more', + actionUrl: 'https://humanatlas.io/2d-ftu-illustrations', + }, + icon: '3d-ftu', + documentationUrl: 'https://humanatlas.io/2d-ftu-illustrations', + }, graph: { label: 'Graphs', tooltip: { diff --git a/libs/design-system/assets/icons/product/3d-ftu.svg b/libs/design-system/assets/icons/product/3d-ftu.svg new file mode 100644 index 0000000000..f586b2d8bc --- /dev/null +++ b/libs/design-system/assets/icons/product/3d-ftu.svg @@ -0,0 +1,9 @@ + + + + + + + + + From 8e6f612456eb3314833699f3ee1cc1a6d85f269b Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 29 Apr 2026 18:11:24 -0400 Subject: [PATCH 18/19] Update organ icons in design system --- .../assets/icons/organ/fallopian-tube.svg | 3 ++ .../assets/icons/organ/glomerulus.svg | 26 +++++++++++ .../design-system/assets/icons/organ/knee.svg | 10 ++--- .../assets/icons/organ/larynx.svg | 17 +++----- .../assets/icons/organ/mammary-gland.svg | 16 +------ .../assets/icons/organ/neurons.svg | 19 -------- .../assets/icons/organ/ovaries.svg | 14 +++--- .../assets/icons/organ/pelvis.svg | 19 +++----- .../icons/organ/peripehral-nervous-system.svg | 43 +++++++++++-------- .../assets/icons/organ/renal-pelvis.svg | 6 +++ .../assets/icons/organ/ureters.svg | 3 ++ .../src/lib/icon/icon.component.stories.ts | 32 ++++++++------ 12 files changed, 109 insertions(+), 99 deletions(-) create mode 100644 libs/design-system/assets/icons/organ/fallopian-tube.svg create mode 100644 libs/design-system/assets/icons/organ/glomerulus.svg delete mode 100644 libs/design-system/assets/icons/organ/neurons.svg create mode 100644 libs/design-system/assets/icons/organ/renal-pelvis.svg create mode 100644 libs/design-system/assets/icons/organ/ureters.svg diff --git a/libs/design-system/assets/icons/organ/fallopian-tube.svg b/libs/design-system/assets/icons/organ/fallopian-tube.svg new file mode 100644 index 0000000000..d7487b70f9 --- /dev/null +++ b/libs/design-system/assets/icons/organ/fallopian-tube.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/design-system/assets/icons/organ/glomerulus.svg b/libs/design-system/assets/icons/organ/glomerulus.svg new file mode 100644 index 0000000000..9b1a94bf40 --- /dev/null +++ b/libs/design-system/assets/icons/organ/glomerulus.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/design-system/assets/icons/organ/knee.svg b/libs/design-system/assets/icons/organ/knee.svg index 7cd91533c2..6fda3b7402 100644 --- a/libs/design-system/assets/icons/organ/knee.svg +++ b/libs/design-system/assets/icons/organ/knee.svg @@ -1,10 +1,10 @@ - - - + + + - - + + diff --git a/libs/design-system/assets/icons/organ/larynx.svg b/libs/design-system/assets/icons/organ/larynx.svg index 2cde08130a..b83f898aa4 100644 --- a/libs/design-system/assets/icons/organ/larynx.svg +++ b/libs/design-system/assets/icons/organ/larynx.svg @@ -1,13 +1,6 @@ - - - - - - - - - - - - + + + + + diff --git a/libs/design-system/assets/icons/organ/mammary-gland.svg b/libs/design-system/assets/icons/organ/mammary-gland.svg index 17a100aee7..03365d9408 100644 --- a/libs/design-system/assets/icons/organ/mammary-gland.svg +++ b/libs/design-system/assets/icons/organ/mammary-gland.svg @@ -1,15 +1,3 @@ - - - - - - - - - - - - - - + + diff --git a/libs/design-system/assets/icons/organ/neurons.svg b/libs/design-system/assets/icons/organ/neurons.svg deleted file mode 100644 index 0a8190a477..0000000000 --- a/libs/design-system/assets/icons/organ/neurons.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/libs/design-system/assets/icons/organ/ovaries.svg b/libs/design-system/assets/icons/organ/ovaries.svg index 9eab40daf7..8bac533f16 100644 --- a/libs/design-system/assets/icons/organ/ovaries.svg +++ b/libs/design-system/assets/icons/organ/ovaries.svg @@ -1,12 +1,12 @@ - - - - - + + + + + - - + + diff --git a/libs/design-system/assets/icons/organ/pelvis.svg b/libs/design-system/assets/icons/organ/pelvis.svg index dffbcc0d5b..6eb36d8c0b 100644 --- a/libs/design-system/assets/icons/organ/pelvis.svg +++ b/libs/design-system/assets/icons/organ/pelvis.svg @@ -1,17 +1,12 @@ - - - - - - - + + + + + - - - - - + + diff --git a/libs/design-system/assets/icons/organ/peripehral-nervous-system.svg b/libs/design-system/assets/icons/organ/peripehral-nervous-system.svg index 4af7edc695..d298c687d7 100644 --- a/libs/design-system/assets/icons/organ/peripehral-nervous-system.svg +++ b/libs/design-system/assets/icons/organ/peripehral-nervous-system.svg @@ -1,19 +1,26 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/design-system/assets/icons/organ/renal-pelvis.svg b/libs/design-system/assets/icons/organ/renal-pelvis.svg new file mode 100644 index 0000000000..5c016fd502 --- /dev/null +++ b/libs/design-system/assets/icons/organ/renal-pelvis.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/libs/design-system/assets/icons/organ/ureters.svg b/libs/design-system/assets/icons/organ/ureters.svg new file mode 100644 index 0000000000..b3b89a70b5 --- /dev/null +++ b/libs/design-system/assets/icons/organ/ureters.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/design-system/icons/src/lib/icon/icon.component.stories.ts b/libs/design-system/icons/src/lib/icon/icon.component.stories.ts index 5545495ff1..a566413b75 100644 --- a/libs/design-system/icons/src/lib/icon/icon.component.stories.ts +++ b/libs/design-system/icons/src/lib/icon/icon.component.stories.ts @@ -57,60 +57,68 @@ export const MiscIcons: Story = { export const OrganIcons: Story = { args: { - name: 'organ:bladder', + name: 'organ:all-organs', }, argTypes: { name: { control: 'select', options: [ + 'organ:adipose-tissue', 'organ:all-organs', + 'organ:anatomical-systems', 'organ:bladder', 'organ:blood', 'organ:bone-marrow', 'organ:brain', - 'organ:breast', + 'organ:extrapulmonary-bronchus', 'organ:eye', 'organ:fallopian-tube-left', 'organ:fallopian-tube-right', + 'organ:fallopian-tube', + 'organ:glomerulus', 'organ:heart', + 'organ:intervertebral-disc', 'organ:kidney-left', 'organ:kidney-right', 'organ:kidneys', 'organ:knee', 'organ:large-intestine', + 'organ:larynx', 'organ:liver', 'organ:lung-left', 'organ:lung-right', 'organ:lungs', 'organ:lymph-node', - 'organ:neurons', - 'organ:ovaries', + 'organ:mammary-gland', + 'organ:manubrium', + 'organ:mouth', + 'organ:muscular-system', 'organ:ovary-left', 'organ:ovary-right', + 'organ:ovaries', + 'organ:palatine-tonsil', 'organ:pancreas', 'organ:pelvis', 'organ:peripehral-nervous-system', 'organ:placenta', 'organ:prostate', + 'organ:renal-pelvis-left', + 'organ:renal-pelvis-right', + 'organ:renal-pelvis', 'organ:skin', 'organ:small-intestine', 'organ:spinal-cord', 'organ:spleen', + 'organ:sternum', 'organ:stomach', 'organ:thymus', + 'organ:trachea', 'organ:ureter-left', 'organ:ureter-right', + 'organ:ureters', 'organ:uterus', 'organ:vasculature-thick', 'organ:vasculature-thin', - 'organ:extrapulmonary-bronchus', - 'organ:larynx', - 'organ:palatine-tonsil', - 'organ:trachea', - 'organ:adipose', - 'organ:anatomical-systems', - 'organ:intervertebral-disc', - 'organ:muscular-system', ], description: 'Icon name', }, From c07a57c4c53d2b1886b21067342ca8bc7e88020a Mon Sep 17 00:00:00 2001 From: edlu77 Date: Wed, 29 Apr 2026 18:41:18 -0400 Subject: [PATCH 19/19] Update kg explorer organ icon map --- apps/kg-explorer/src/app/utils/utils.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index b89daa74dc..d594e68d2f 100644 --- a/apps/kg-explorer/src/app/utils/utils.ts +++ b/apps/kg-explorer/src/app/utils/utils.ts @@ -249,10 +249,11 @@ export const ORGAN_ICON_MAP: Record = { 'http://purl.obolibrary.org/obo/UBERON_0004537': 'vasculature-thick', //blood vasculature 'http://purl.obolibrary.org/obo/UBERON_0014455': 'adipose', 'http://purl.obolibrary.org/obo/UBERON_0000467': 'anatomical-systems', + 'http://purl.obolibrary.org/obo/UBERON_0002371': 'bone-marrow', 'http://purl.obolibrary.org/obo/UBERON_0000955': 'brain', 'http://purl.obolibrary.org/obo/UBERON_0002182': 'extrapulmonary-bronchus', 'http://purl.obolibrary.org/obo/UBERON_0000970': 'eye', - 'http://purl.obolibrary.org/obo/UBERON_0003889': 'fallopian-tube-left', //fallopian tube + 'http://purl.obolibrary.org/obo/UBERON_0003889': 'fallopian-tube', 'http://purl.obolibrary.org/obo/UBERON_0000948': 'heart', 'http://purl.obolibrary.org/obo/UBERON_0001066': 'intervertebral-disk', 'http://purl.obolibrary.org/obo/UBERON_0002113': 'kidneys', //kidney @@ -260,24 +261,25 @@ export const ORGAN_ICON_MAP: Record = { 'http://purl.obolibrary.org/obo/UBERON_0000059': 'large-intestine', 'http://purl.obolibrary.org/obo/UBERON_0002107': 'liver', 'http://purl.obolibrary.org/obo/UBERON_0002048': 'lungs', //lung - 'http://purl.obolibrary.org/obo/UBERON_0000029': 'lymph-node', //lymph node - 'http://purl.obolibrary.org/obo/UBERON_0004536': 'lymph-node', //lymph vasculature + 'http://purl.obolibrary.org/obo/UBERON_0000029': 'lymph-node', + 'http://purl.obolibrary.org/obo/UBERON_0004536': 'lymph-node', //lymph-vasculature 'http://purl.obolibrary.org/obo/UBERON_0000165': 'mouth', 'http://purl.obolibrary.org/obo/UBERON_0000383': 'muscular-system', 'http://purl.obolibrary.org/obo/UBERON_0000992': 'ovaries', //ovary + 'http://purl.obolibrary.org/obo/UBERON_0002373': 'palatine-tonsil', 'http://purl.obolibrary.org/obo/UBERON_0001264': 'pancreas', - 'http://purl.obolibrary.org/obo/UBERON_0001270': 'pelvis', + 'http://purl.obolibrary.org/obo/UBERON_0001270': 'pelvis', //blood-pelvis 'http://purl.obolibrary.org/obo/UBERON_0001987': 'placenta', - 'http://purl.obolibrary.org/obo/UBERON_0002367': 'prostate', //prostate gland + 'http://purl.obolibrary.org/obo/UBERON_0002367': 'prostate', 'http://purl.obolibrary.org/obo/UBERON_0004288': 'sternum', //skeleton 'http://purl.obolibrary.org/obo/UBERON_0002097': 'skin', 'http://purl.obolibrary.org/obo/UBERON_0002108': 'small-intestine', 'http://purl.obolibrary.org/obo/UBERON_0002240': 'spinal-cord', 'http://purl.obolibrary.org/obo/UBERON_0002106': 'spleen', - 'http://purl.obolibrary.org/obo/UBERON_0002370': 'thymus', //thoracic thymus + 'http://purl.obolibrary.org/obo/UBERON_0002370': 'thymus', 'http://purl.obolibrary.org/obo/UBERON_0003126': 'trachea', - 'http://purl.obolibrary.org/obo/UBERON_0000056': 'ureter-right', //ureter - 'http://purl.obolibrary.org/obo/UBERON_0001255': 'bladder', //urinary bladder + 'http://purl.obolibrary.org/obo/UBERON_0000056': 'ureters', //ureter + 'http://purl.obolibrary.org/obo/UBERON_0001255': 'bladder', //urinary-bladder 'http://purl.obolibrary.org/obo/UBERON_0000995': 'uterus', }; @@ -330,13 +332,13 @@ export const HRA_VERSION_DATA: Record = }; /** - * Gets organ id from a digital object. If more than one organ is listed return undefined + * Gets organ id from a digital object. If more than one organ is listed return the first one, if no organs are listed return undefined * @param item Digital object data item * @returns Organ id */ export function getOrganId(item?: DigitalObjectInfo): string | undefined { const ids = coerceArray(item?.organIds); - return ids.length === 1 ? ids[0] : undefined; + return ids.length > 0 ? ids[0] : undefined; } /**