diff --git a/apps/kg-explorer/src/app/app.component.ts b/apps/kg-explorer/src/app/app.component.ts index 98e2443424..f737844107 100644 --- a/apps/kg-explorer/src/app/app.component.ts +++ b/apps/kg-explorer/src/app/app.component.ts @@ -1,25 +1,17 @@ -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 { setMirrorUrl, setRemoteApiEndpoint } from './utils/endpoints'; +import { DigitalObjectsJsonLd } from './digital-objects-metadata.schema'; +import { injectMirrorUrl, setMirrorUrl, setRemoteApiEndpoint } from './utils/endpoints'; import { isNavigating } from './utils/navigation'; import { routeData } from './utils/route-data'; @@ -76,8 +68,9 @@ 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); + + readonly mirrorUrl = injectMirrorUrl(); /** Page title to display on the breadcrumbs */ private readonly pageTitle = signal(''); @@ -116,10 +109,15 @@ 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; + private readonly digitalObjects = signal({ '@context': {}, '@graph': [] }); /** * Gets the page title for breadcrumbs @@ -127,6 +125,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 customMirrorUrl = el.getAttribute('mirror-url'); + if (customMirrorUrl) { + setMirrorUrl(customMirrorUrl); + } + effect(() => { if (this.typeLabel() && this.documentationUrl()) { this.extraMenuOption.set({ @@ -149,13 +157,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,18 +165,18 @@ export class AppComponent extends BaseApplicationComponent { } else { this.params.set([]); } - }); - 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); - } + this.setPageTitle(); + }); + } - this.digitalObjects = toSignal(this.kg.digitalObjects(), { initialValue: {} }); + private setPageTitle() { + 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(); + 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 91008bb9c8..869926d531 100644 --- a/apps/kg-explorer/src/app/app.routes.ts +++ b/apps/kg-explorer/src/app/app.routes.ts @@ -3,16 +3,13 @@ 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 { 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, - kgResolver, - ontologyResolver, + kgJsonResolver, productLabelResolver, } from './utils/kg-resolver'; @@ -111,11 +108,9 @@ export const appRoutes: Route[] = [ columns: DO_COLUMNS, }, resolve: { - data: kgResolver(), - asctbTermOccurrences: asctbResolver(), - ontologyTree: ontologyResolver(), - cellTypeTree: cellTypeResolver(), - biomarkerTree: biomarkersResolver(), + data: kgJsonResolver('/kg/digital-objects.jsonld', DigitalObjectsJsonLdSchema), + asctbTerms: kgJsonResolver('/kg/asctb-terms.json', AsctbTermsSchema), + termsIndex: kgJsonResolver('/kg/kg-terms-index.json', TermsIndexSchema), }, }, { @@ -125,7 +120,8 @@ export const appRoutes: Route[] = [ columns: METADATA_COLUMNS, }, resolve: { - doData: kgResolver(), + 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/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 d76147fdc8..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,8 +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 { 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,8 +27,23 @@ export interface FilterFormValues { biomarkers: FilterOption[] | null; } -/** Filter types for the filter form */ -type FilterType = 'digitalObjects' | 'releaseVersion' | 'organs' | 'anatomicalStructures' | 'cellTypes' | 'biomarkers'; +/** 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/digital-objects-metadata.schema.ts b/apps/kg-explorer/src/app/digital-objects-metadata.schema.ts index 8c26eea7d4..4ae2d2f96a 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,59 @@ 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.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' }); + +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..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 @@ -22,9 +22,9 @@ > @@ -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([]); + readonly asctbTerms = input.required(); + readonly termsIndex = input.required(); + /** Filtered rows to display */ readonly filteredRows = signal([]); /** Whether or not the filter menu is closed */ readonly filterClosed = signal(false); /** Filter categories */ - readonly filterCategories = signal>(FILTER_CATEGORY_INFO); - /** Currently selected filters */ - readonly filters = signal({}); + readonly filterCategories = 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); - /** Filter categories as an array */ - readonly filterCategoriesArray = computed(() => Object.values(this.filterCategories())); + 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 @@ -150,28 +146,30 @@ export class MainPageComponent { */ constructor() { const queryParams$ = inject(ActivatedRoute).queryParams; - queryParams$.subscribe((queryParams) => this.setFiltersFomParams(queryParams)); + queryParams$.subscribe((queryParams) => this.setFiltersFromQueryParams(queryParams)); - toObservable(this.data).subscribe((items) => { - const objectData = this.resolveData(items['@graph']); - this.allRows.set(objectData); - this.filteredRows.set(this.allRows()); - this.setVersionCounts(items['@graph'] as DigitalObjectInfoWithHraVersions[]); - }); + 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); this.searchControl.valueChanges.subscribe((result?: string) => { - this.onSearchChange(result === '' ? undefined : result); + this.store.setSearchTerm(result && result.length > 0 ? result : null); + this.updateQueryParamsFromFilters(); }); effect(() => { this.populateFilterOptions(); - this.digitalObjectSearch().subscribe((results) => { - this.applyMoreFilters(results); - }); + this.attachDownloadOptions(); }); effect(() => { - this.attachDownloadOptions(); + 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(); @@ -182,7 +180,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']; @@ -191,53 +189,14 @@ export class MainPageComponent { const b = queryParams['b']; const search = queryParams['search']; - 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, - 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 - */ - 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.updateQueryParamsFromFilters(); + 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); } /** @@ -246,303 +205,67 @@ 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(), }, }); } /** - * 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; - } - - /** - * 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.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 organsOptions(): FilterOption[] { - return Array.from(this.kgFilterOptions().organOptions) - .map((organOption) => { - return { - id: organOption, - label: sentenceCase(this.ontologyTree()?.nodes[organOption]?.label || ''), - count: this.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 + * Updates current filter selections when changed + * @param formControls */ - 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)); + handleFilterSelectionChanges(formValues: FilterFormValues) { + this.store.updateFiltersFromForm(formValues); + this.updateQueryParamsFromFilters(); } /** * Populates filter categories with options */ private populateFilterOptions() { - this.filterCategories.update((categories) => { - return { - digitalObjects: { - ...categories['digitalObjects'], - options: this.digitalObjectsOptions(), - }, - releaseVersions: { - ...categories['releaseVersions'], - options: this.hraVersionsOptions(), - }, - organs: { - ...categories['organs'], - options: this.organsOptions(), - }, - anatomicalStructures: { - ...categories['anatomicalStructures'], - options: this.ontologyOptions(this.ontologyTree()), - }, - cellTypes: { - ...categories['cellTypes'], - options: this.ontologyOptions(this.cellTypeTree()), - }, - biomarkers: { - ...categories['biomarkers'], - options: this.ontologyOptions(this.biomarkerTree()), - }, - }; - }); - } - - /** - * Applies additional filters to digital objects obtained from KG search and sets new filtered rows - */ - private applyMoreFilters(searchResults: string[]) { - let newFilteredRows = this.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); - } - - if (this.filters().organs) { - newFilteredRows = this.filterOrganResults(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)); - } - - /** - * 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) => - ((row['organIds'] as string[]) ?? []).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 { - 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, - }); - } - - /** - * 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) => { - const type = row['doType']; - objectFilterOptions.add(type as string); - const organs = row['organIds'] as string[]; - if (organs) { - for (const organ of organs) { - organFilterOptions.add(organ); - } - } - }); - return { - doOptions: objectFilterOptions, - organOptions: organFilterOptions, - }; - } - - /** - * Resolves raw digital object data into array of TableRow - * @param data Raw digital object data - * @returns Data as TableRow[] - */ - private resolveData(data?: DigitalObjectInfo[]): TableRow[] { - if (!data) { - return []; - } - return data.map((item) => { - const organId = getOrganId(item); - const organLabel = this.ontologyTree()?.nodes[organId]?.label; - return { - id: item.lod, - purl: item.purl, - doType: item.doType, - 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), - } as TableRow; - }); + 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); } /** * 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.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); } }); - } - } - - /** - * Updates filteredRows on searchTerm input - * @param searchTerm Search input - */ - private onSearchChange(searchTerm?: string): void { - this.filters.set({ - ...this.filters(), - searchTerm, - }); - - this.updateQueryParamsFromFilters(); } /** - * Formats Date to yyyy-mm - * @param dateString Date string - * @returns Date as yyyy-mm + * Returns the metadata endpoint for a digital object id. + * @param downloadId Digital object dataset id + * @returns Metadata url */ - 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 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 172d6208c4..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 @@ -1,10 +1,8 @@ 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 { DigitalObjectsJsonLd, 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 +13,16 @@ 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 { + AsctbTerms, + DigitalObjectInfo, + DigitalObjectMetadata, + DigitalObjectsJsonLd, + PersonInfo, +} from '../../digital-objects-metadata.schema'; import { DownloadService } from '../../services/download.service'; -import { 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 @@ -45,16 +50,25 @@ 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); + + readonly mirrorUrl = injectMirrorUrl(); /** 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 */ readonly metadata = input.required(); + 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 */ @@ -76,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', @@ -94,66 +109,23 @@ 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); - - this.v1.ontologyTreeModel({}).subscribe((ontologyData) => { - if (pageItem) { - 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', - }); - } - this.tags.set(tags); - } - }); + effect(() => { + const pageItem = this.allItems().find((item) => { + return item['@id'] === `${this.baseUrl()}/${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); } }); } @@ -213,4 +185,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); + } } 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..13741be3d1 --- /dev/null +++ b/apps/kg-explorer/src/app/services/search.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@angular/core'; +import { TableRow } from '@hra-ui/design-system/table'; +import { Observable, of } from 'rxjs'; +import { TermsIndex } from '../digital-objects-metadata.schema'; +import { coerceArray } from '../utils/utils'; + +@Injectable({ + providedIn: 'root', +}) +export class SearchService { + search( + rows: TableRow[], + termsIndex: TermsIndex, + options: { + searchTerm: string | null; + digitalObjects: string[]; + versions: string[]; + organs: string[]; + ontologyTerms: string[]; + cellTypeTerms: string[]; + biomarkerTerms: string[]; + }, + ): 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 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); + } + + private getPurlsFromTerms(terms: string[], termsIndex: TermsIndex): Set { + const purls = new Set(); + terms.forEach((term) => { + const purlIndexes = termsIndex.term_to_purls[termsIndex.terms.indexOf(term)]; + if (purlIndexes) { + purlIndexes.forEach((purlIndex) => purls.add(termsIndex.purls[purlIndex])); + } + }); + 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); + }; + } +} 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..010f82f30c --- /dev/null +++ b/apps/kg-explorer/src/app/state/filters.store.ts @@ -0,0 +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' }, 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..b729365f1e --- /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), + } 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() === 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 new file mode 100644 index 0000000000..d0d1449c06 --- /dev/null +++ b/apps/kg-explorer/src/app/state/with-filters.feature.ts @@ -0,0 +1,77 @@ +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; + releaseVersion: string[] | null; + organs: string[] | null; + anatomicalStructures: string[] | null; + cellTypes: string[] | null; + biomarkers: string[] | null; + searchTerm: string | null; +} + +/** Initial state for the filters store */ +const initialState: FiltersState = { + 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(), + })); + + return { + currentFilters, + }; + }), + withMethods((store) => ({ + 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) }), + ), + setSearchTerm: signalMethod((searchTerm: string | null) => patchState(store, { searchTerm })), + + updateFiltersFromForm: signalMethod((formValues: FilterFormValues) => { + 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(), + }); + }), + })), + ); +} diff --git a/apps/kg-explorer/src/app/utils/kg-resolver.ts b/apps/kg-explorer/src/app/utils/kg-resolver.ts index 07a4fc3fd6..f7c034d517 100644 --- a/apps/kg-explorer/src/app/utils/kg-resolver.ts +++ b/apps/kg-explorer/src/app/utils/kg-resolver.ts @@ -1,29 +1,32 @@ 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 { 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 that fetches the digital object data from a url - * @returns Resolver + * 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 kgResolver(): ResolveFn { - return () => { - const kg = inject(HraKgService); - return kg.digitalObjects(); +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') || ''; @@ -36,56 +39,12 @@ 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 */ export function documentationUrlResolver(): ResolveFn { - return (route: ActivatedRouteSnapshot) => { + return (route) => { const type = route.params['type']; return getDocumentationUrl(type); }; @@ -96,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); }; diff --git a/apps/kg-explorer/src/app/utils/utils.ts b/apps/kg-explorer/src/app/utils/utils.ts index 9442761aa7..d594e68d2f 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 { @@ -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.', @@ -153,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: { @@ -220,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 @@ -231,29 +261,34 @@ 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', }; /** 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', @@ -297,12 +332,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 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 { - return item?.organIds && item.organIds.length === 1 ? item.organIds[0] : ''; +export function getOrganId(item?: DigitalObjectInfo): string | undefined { + const ids = coerceArray(item?.organIds); + return ids.length > 0 ? ids[0] : undefined; } /** @@ -311,7 +347,10 @@ 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)) { + return `organ:${ORGAN_ICON_MAP[getOrganId(item) as string] ?? 'all-organs'}`; + } + return 'organ:all-organs'; } /** @@ -359,3 +398,26 @@ export function sentenceCase(value: string): string { const processedValue = value.trim().toLowerCase(); return processedValue.charAt(0).toUpperCase() + processedValue.slice(1); } + +export function coerceArray(value: string | string[] | undefined): string[] { + switch (typeof value) { + case 'undefined': + return []; + case 'string': + return [value]; + default: + 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}`; +} 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/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 @@ + + + + + + + + + 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', },