diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-24hour.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-24hour.png new file mode 100644 index 0000000000..0a708cce5d Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-24hour.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-celebration.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-celebration.png new file mode 100644 index 0000000000..02229f0014 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-celebration.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-exhibit.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-exhibit.png new file mode 100644 index 0000000000..df2c128454 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-exhibit.png differ diff --git a/apps/cns-website/src/app/pages/research-page/state/research.store.ts b/apps/cns-website/src/app/pages/research-page/state/research.store.ts index 2d35467f10..1e9e2556f2 100644 --- a/apps/cns-website/src/app/pages/research-page/state/research.store.ts +++ b/apps/cns-website/src/app/pages/research-page/state/research.store.ts @@ -7,6 +7,7 @@ import { parseFundingIds, parseGroupBy, parsePeopleIds, + parseProjects, parsePublicationIds, parseSearch, parseSortBy, @@ -16,6 +17,7 @@ import { serializeEventIds, serializeFundingIds, serializePeopleIds, + serializeProjects, serializePublicationIds, serializeYears, } from './serialization'; @@ -55,6 +57,7 @@ export const ResearchStore = signalStore( const funding = createWritableStateSlice(store.fundingIds, store.setFundingIds); const publications = createWritableStateSlice(store.publicationIds, store.setPublicationIds); const people = createWritableStateSlice(store.peopleIds, store.setPeopleIds); + const projects = createWritableStateSlice(store.projects, store.setProjects); const years = createWritableStateSlice(store.years, store.setYears); const search = createWritableStateSlice(store.search, store.setSearch); const sortBy = createWritableStateSlice(store._sortBy, store.setSortBy); @@ -91,6 +94,12 @@ export const ResearchStore = signalStore( stringify: serializePeopleIds, ...commonOptions, }); + linkedQueryParam('project', { + source: projects, + parse: parseProjects, + stringify: serializeProjects, + ...commonOptions, + }); linkedQueryParam('year', { source: years, parse: parseYears, diff --git a/apps/cns-website/src/app/pages/research-page/state/serialization.ts b/apps/cns-website/src/app/pages/research-page/state/serialization.ts index 19881ab23a..9d391d7275 100644 --- a/apps/cns-website/src/app/pages/research-page/state/serialization.ts +++ b/apps/cns-website/src/app/pages/research-page/state/serialization.ts @@ -1,4 +1,11 @@ -import { CATEGORY_OPTIONS, CategoryOption, YEAR_OPTIONS, YearOption } from './with-filters.feature'; +import { + CATEGORY_OPTIONS, + CategoryOption, + PROJECT_OPTIONS, + ProjectsOption, + YEAR_OPTIONS, + YearOption, +} from './with-filters.feature'; import { GroupBy, SortBy } from './with-ordering.feature'; import { View } from './with-view.feature'; @@ -67,6 +74,10 @@ export function parseCategories(value: unknown): CategoryOption[] | null { return parseOptions(CATEGORY_OPTIONS, value); } +export function parseProjects(value: unknown): ProjectsOption[] | null { + return parseOptions(PROJECT_OPTIONS, value); +} + /** * Parses event query parameter into event options. * @param value Raw query value @@ -159,6 +170,10 @@ export function serializeCategories(options: CategoryOption[] | null): string | return serializeOptions(options); } +export function serializeProjects(options: ProjectsOption[] | null): string | null { + return serializeOptions(options); +} + /** * Serializes selected events to query parameter format. * @param ids Selected event IDs diff --git a/apps/cns-website/src/app/pages/research-page/state/with-filters.feature.ts b/apps/cns-website/src/app/pages/research-page/state/with-filters.feature.ts index ce5a2e7e48..f141b5e4c8 100644 --- a/apps/cns-website/src/app/pages/research-page/state/with-filters.feature.ts +++ b/apps/cns-website/src/app/pages/research-page/state/with-filters.feature.ts @@ -14,6 +14,7 @@ import { PeopleId } from '../../../schemas/people.schema'; import { ResearchTypeId, ResearchTypeItem } from '../../../schemas/research-type.schema'; import { ResearchCategoryId, ResearchItem } from '../../../schemas/research.schema'; import { ResearchState } from './with-research.feature'; +import { TagId } from '../../../schemas/tags.schema'; /** Generic search list option with a typed id */ type TypedSearchListOption = SearchListOption & { id: T }; @@ -33,6 +34,8 @@ export type PublicationOption = TypedSearchListOption; /** Filter option for people */ export type PeopleOption = TypedSearchListOption; +export type ProjectsOption = TypedSearchListOption; + /** Year option with numeric year value */ export interface YearOption extends SearchListOption { /** Year value */ @@ -59,6 +62,8 @@ export interface FilterProps { countsByPublicationType: Signal>; /** Counts by people */ countsByPeople: Signal>; + /** Counts by project */ + countsByProject: Signal>; /** Counts by year */ countsByYear: Signal>; /** Aggregate counts array */ @@ -80,6 +85,8 @@ interface FilterState { fundingIds: string[] | null; /** Selected people IDs */ peopleIds: string[] | null; + /** Selected project */ + projects: ProjectsOption[] | null; /** Selected years */ years: YearOption[] | null; /** Search text */ @@ -114,6 +121,15 @@ export const CATEGORY_OPTIONS: CategoryOption[] = [ { id: 'visualization' as ResearchCategoryId, label: 'Visualizations' }, ]; +export const PROJECT_OPTIONS: ProjectsOption[] = [ + { id: 'amatria' as TagId, label: 'Amatria' }, + { id: 'envisioning-intelligences' as TagId, label: 'Envisioning Intelligences' }, + { id: 'hra' as TagId, label: 'Human Reference Atlas' }, + { id: 'macroscopes' as TagId, label: 'Macroscopes' }, + { id: 'maps' as TagId, label: 'Maps' }, + { id: 'whole-person-physiome' as TagId, label: 'Whole Person Physiome' }, +]; + /** Year filter options from 1991 to current year */ export const YEAR_OPTIONS: YearOption[] = createYearList(1991).map((year) => ({ id: year.toString(), @@ -161,6 +177,14 @@ const PEOPLE_FILTER: FilterOptionCategory = { selected: [], }; +/** Projects filter configuration */ +const PROJECTS_FILTER: FilterOptionCategory = { + id: 'project', + label: 'Project', + options: PROJECT_OPTIONS, + selected: [], +}; + /** Year filter configuration */ const YEARS_FILTER: FilterOptionCategory = { id: 'year', @@ -176,6 +200,7 @@ const initialState: FilterState = { eventIds: null, fundingIds: null, peopleIds: null, + projects: null, years: null, search: null, }; @@ -337,6 +362,7 @@ export function withFilters() { const _fundingFilter = optionsToFilter(FUNDING_FILTER, funding, _fundingOptions); const _publicationsFilter = optionsToFilter(PUBLICATIONS_FILTER, publications, _publicationOptions); const _peopleFilter = optionsToFilter(PEOPLE_FILTER, people, _peopleOptions); + const _projectsFilter = optionsToFilter(PROJECTS_FILTER, store.projects); const _yearsFilter = optionsToFilter(YEARS_FILTER, store.years); const filters = computed((): FilterOptionCategory[] => [ @@ -345,6 +371,7 @@ export function withFilters() { _fundingFilter(), _publicationsFilter(), _peopleFilter(), + _projectsFilter(), _yearsFilter(), ]); @@ -365,8 +392,16 @@ export function withFilters() { item.people.some((person) => selectedPeople.has(person)), ); + const _selectedProjects = optionsToSet(store.projects); + const _filteredByProject = createFilteredBy(_filteredByPeople, _selectedProjects, (item, selectedProjects) => { + if (item.projects) { + return item.projects.some((project) => selectedProjects.has(project)); + } + return item.tags.some((tag) => selectedProjects.has(tag)); + }); + const _selectedYears = computed(() => new Set(store.years()?.map((option) => option.year) ?? [])); - const _filteredByYear = createFilteredBy(_filteredByPeople, _selectedYears, (item, selectedYears) => + const _filteredByYear = createFilteredBy(_filteredByProject, _selectedYears, (item, selectedYears) => selectedYears.has(item.dateStart.getFullYear()), ); @@ -402,6 +437,7 @@ export function withFilters() { (item) => item.category === 'publication', ); const countsByPeople = countsByKey(store.researchItems, (item) => item.people); + const countsByProject = countsByKey(store.researchItems, (item) => item.projects || item.tags); const countsByYear = countsByKey(store.researchItems, (item) => item.dateStart.getFullYear().toString()); const counts = computed(() => [ @@ -410,6 +446,7 @@ export function withFilters() { countsByFundingType(), countsByPublicationType(), countsByPeople(), + countsByProject(), countsByYear(), ]); @@ -423,11 +460,13 @@ export function withFilters() { countsByFundingType, countsByPublicationType, countsByPeople, + countsByProject, countsByYear, counts, _filteredByCategory, _filteredByType, _filteredByPeople, + _filteredByProject, _filteredByYear, } satisfies FilterProps & InternalProps; }), @@ -449,6 +488,7 @@ export function withFilters() { setPeople: signalMethod((people: PeopleOption[] | null) => patchState(store, { peopleIds: people?.map((p) => p.id) ?? null }), ), + setProjects: signalMethod((projects: ProjectsOption[] | null) => patchState(store, { projects })), /** * Sets selected years. * @param years Selected year options @@ -469,7 +509,8 @@ export function withFilters() { const funding = filters[2]?.selected as FundingOption[]; const publications = filters[3]?.selected as PublicationOption[]; const people = filters[4]?.selected as PeopleOption[]; - const years = filters[5]?.selected as YearOption[]; + const projects = filters[5]?.selected as ProjectsOption[]; + const years = filters[6]?.selected as YearOption[]; patchState(store, { categories: categories.length > 0 ? categories : null, @@ -477,6 +518,7 @@ export function withFilters() { eventIds: events.length > 0 ? events.map((e) => e.id) : null, fundingIds: funding.length > 0 ? funding.map((f) => f.id) : null, peopleIds: people.length > 0 ? people.map((p) => p.id) : null, + projects: projects.length > 0 ? projects : null, years: years.length > 0 ? years : null, }); }), diff --git a/apps/cns-website/src/app/schemas/research.schema.ts b/apps/cns-website/src/app/schemas/research.schema.ts index 4001cf15a7..240b948dc9 100644 --- a/apps/cns-website/src/app/schemas/research.schema.ts +++ b/apps/cns-website/src/app/schemas/research.schema.ts @@ -40,6 +40,11 @@ export const ResearchItemSchema = z people: z.array(PeopleIdSchema), /** Tags for categorizing the research */ tags: z.array(TagIdSchema).transform((tags) => uniqueValues(tags)), + /** Projects associated with the research (uses tag ids) */ + projects: z + .array(TagIdSchema) + .transform((projects) => uniqueValues(projects)) + .optional(), /** Image source URL */ image: z.string().optional(), }) diff --git a/libs/design-system/filter-menu/src/lib/filter-menu.component.html b/libs/design-system/filter-menu/src/lib/filter-menu.component.html index f9b9d74d5d..090b073fd6 100644 --- a/libs/design-system/filter-menu/src/lib/filter-menu.component.html +++ b/libs/design-system/filter-menu/src/lib/filter-menu.component.html @@ -35,39 +35,40 @@ Filter @for (filter of filtersWithCounts(); track $index) { - - - - - + + + + } } diff --git a/libs/design-system/filter-menu/src/lib/filter-menu.component.ts b/libs/design-system/filter-menu/src/lib/filter-menu.component.ts index 5bf1f6b2d8..507d0a71df 100644 --- a/libs/design-system/filter-menu/src/lib/filter-menu.component.ts +++ b/libs/design-system/filter-menu/src/lib/filter-menu.component.ts @@ -1,5 +1,5 @@ import { ConnectedPosition, OverlayModule } from '@angular/cdk/overlay'; -import { ChangeDetectionStrategy, Component, computed, input, model, output, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, input, model, output, signal } from '@angular/core'; import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; import { HraCommonModule } from '@hra-ui/common'; import { ButtonsModule } from '@hra-ui/design-system/buttons'; @@ -67,7 +67,7 @@ export class FilterMenuComponent { readonly filters = model.required[]>(); /** Counts for each filter option */ - readonly counts = input[]>(); + readonly counts = input[]>([]); /** Emits when the form opening state is toggled */ readonly closeClick = output(); @@ -92,6 +92,10 @@ export class FilterMenuComponent { return this.filters(); }); + isExtraCategory(category: FilterOptionCategory): boolean { + return Object.keys(this.counts()[this.filters().indexOf(category)]).length < 2; + } + /** * Updates filters on filter selection * @param category Filter category to update diff --git a/libs/design-system/search-list/src/lib/search-list.component.html b/libs/design-system/search-list/src/lib/search-list.component.html index b312f8f531..723234abd6 100644 --- a/libs/design-system/search-list/src/lib/search-list.component.html +++ b/libs/design-system/search-list/src/lib/search-list.component.html @@ -12,27 +12,27 @@ (selectionChange)="selectionUpdate($event.source.selectedOptions.selected)" > @for (option of filteredOptions(); track option.id) { - - - -
{{ option.label }}
- @if (option.secondaryLabel) { -
{{ option.secondaryLabel }}
- } -
- @let count = getCount(option); - @if (count !== undefined) { + @let count = getCount(option); + @if (count) { + + + +
{{ option.label }}
+ @if (option.secondaryLabel) { +
{{ option.secondaryLabel }}
+ } +
{{ count | number }} - } -
-
+
+
+ } }