File

src/app/pages/research-page/state/with-filters.feature.ts

Description

Selected filter values for the research page

Index

Properties

Properties

categories
categories: CategoryOption[] | null
Type : CategoryOption[] | null

Selected categories

eventIds
eventIds: string[] | null
Type : string[] | null

Selected event IDs

fundingIds
fundingIds: string[] | null
Type : string[] | null

Selected funding IDs

peopleIds
peopleIds: string[] | null
Type : string[] | null

Selected people IDs

publicationIds
publicationIds: string[] | null
Type : string[] | null

Selected publication IDs

search
search: string | null
Type : string | null

Search text

years
years: YearOption[] | null
Type : YearOption[] | null

Selected years

import { computed, Signal } from '@angular/core';
import { FilterOptionCategory } from '@hra-ui/design-system/filter-menu';
import { SearchListOption } from '@hra-ui/design-system/search-list';
import {
  patchState,
  signalMethod,
  signalStoreFeature,
  type,
  withComputed,
  withMethods,
  withState,
} from '@ngrx/signals';
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';

/** Generic search list option with a typed id */
type TypedSearchListOption<T extends string> = SearchListOption & { id: T };

/** Filter option for research categories */
export type CategoryOption = TypedSearchListOption<ResearchCategoryId>;

/** Filter option for research events */
export type EventOption = TypedSearchListOption<ResearchTypeId>;

/** Filter option for research funding */
export type FundingOption = TypedSearchListOption<ResearchTypeId>;

/** Filter option for research publications */
export type PublicationOption = TypedSearchListOption<ResearchTypeId>;

/** Filter option for people */
export type PeopleOption = TypedSearchListOption<PeopleId>;

/** Year option with numeric year value */
export interface YearOption extends SearchListOption {
  /** Year value */
  year: number;
}

/** Signals exposed by the filters feature */
export interface FilterProps {
  /** Selected people options */
  people: Signal<PeopleOption[]>;
  /** Available filters with selected options */
  filters: Signal<FilterOptionCategory<SearchListOption>[]>;
  /** Items after all filters applied */
  filteredItems: Signal<ResearchItem[]>;
  /** Count of filtered items */
  numFilteredItems: Signal<number>;
  /** Counts by category */
  countsByCategory: Signal<Record<string, number>>;
  /** Counts by event type */
  countsByEventType: Signal<Record<string, number>>;
  /** Counts by funding type */
  countsByFundingType: Signal<Record<string, number>>;
  /** Counts by publication type */
  countsByPublicationType: Signal<Record<string, number>>;
  /** Counts by people */
  countsByPeople: Signal<Record<string, number>>;
  /** Counts by year */
  countsByYear: Signal<Record<string, number>>;
  /** Aggregate counts array */
  counts: Signal<Record<string, number>[]>;
}

/** Internal filter state backing the signals */
type InternalProps = { [key: `_${string}`]: unknown };

/** Selected filter values for the research page */
interface FilterState {
  /** Selected categories */
  categories: CategoryOption[] | null;
  /** Selected publication IDs */
  publicationIds: string[] | null;
  /** Selected event IDs */
  eventIds: string[] | null;
  /** Selected funding IDs */
  fundingIds: string[] | null;
  /** Selected people IDs */
  peopleIds: string[] | null;
  /** Selected years */
  years: YearOption[] | null;
  /** Search text */
  search: string | null;
}

/**
 * Builds a descending list of years starting at startYear.
 * @param startYear Inclusive starting year
 */
function createYearList(startYear: number): number[] {
  const currentYear = new Date().getFullYear();
  const years: number[] = [];
  for (let year = currentYear; year >= startYear; year--) {
    years.push(year);
  }

  return years;
}

/** Category filter options */
export const CATEGORY_OPTIONS: CategoryOption[] = [
  { id: 'data-tool' as ResearchCategoryId, label: 'Data & tools' },
  { id: 'event' as ResearchCategoryId, label: 'Events' },
  { id: 'funding' as ResearchCategoryId, label: 'Funding' },
  { id: 'display' as ResearchCategoryId, label: 'Interactive displays' },
  { id: 'miscellaneous' as ResearchCategoryId, label: 'Miscellaneous' },
  { id: 'news' as ResearchCategoryId, label: 'News' },
  { id: 'publication' as ResearchCategoryId, label: 'Publications' },
  { id: 'software' as ResearchCategoryId, label: 'Software Products' },
  { id: 'teaching' as ResearchCategoryId, label: 'Teaching' },
  { id: 'visualization' as ResearchCategoryId, label: 'Visualizations' },
];

/** Year filter options from 1991 to current year */
export const YEAR_OPTIONS: YearOption[] = createYearList(1991).map((year) => ({
  id: year.toString(),
  label: year.toString(),
  year,
}));

/** Category filter configuration */
const CATEGORIES_FILTER: FilterOptionCategory<CategoryOption> = {
  id: 'category',
  label: 'Category',
  options: CATEGORY_OPTIONS,
  selected: [],
};

/** Event filter configuration */
const EVENTS_FILTER: FilterOptionCategory<EventOption> = {
  id: 'event-type',
  label: 'Event type',
  options: [],
  selected: [],
};

/** Funding filter configuration */
const FUNDING_FILTER: FilterOptionCategory<FundingOption> = {
  id: 'funding-type',
  label: 'Funding type',
  options: [],
  selected: [],
};

/** Publication filter configuration */
const PUBLICATIONS_FILTER: FilterOptionCategory<PublicationOption> = {
  id: 'publication-type',
  label: 'Publication type',
  options: [],
  selected: [],
};

/** People filter configuration */
const PEOPLE_FILTER: FilterOptionCategory<PeopleOption> = {
  id: 'people',
  label: 'People',
  options: [],
  selected: [],
};

/** Year filter configuration */
const YEARS_FILTER: FilterOptionCategory<YearOption> = {
  id: 'year',
  label: 'Year',
  options: YEAR_OPTIONS,
  selected: [],
};

/** Initial filter state with no selections */
const initialState: FilterState = {
  categories: null,
  publicationIds: null,
  eventIds: null,
  fundingIds: null,
  peopleIds: null,
  years: null,
  search: null,
};

/**
 * Converts research type definitions to typed search list options.
 * @param researchTypes Accessor for research type definitions
 * @return Accessor for typed search list options
 */
function researchTypesToOptions(
  researchTypes: () => ResearchTypeItem[],
): Signal<TypedSearchListOption<ResearchTypeId>[]> {
  return computed(() =>
    researchTypes()
      .map((item) => ({
        id: item.value,
        label: toSentenceCase(item.label),
      }))
      .sort((a, b) => a.label.localeCompare(b.label)),
  );
}

/**
 * Filters options by selected IDs.
 * @param options Accessor for all options
 * @param ids Accessor for selected option IDs
 * @return Accessor for typed search list options
 */
function filterOptionsByIds<T extends string>(
  options: () => TypedSearchListOption<T>[],
  ids: () => string[] | null,
): Signal<TypedSearchListOption<T>[]> {
  return computed(() => {
    const idSet = new Set(ids());
    return options().filter((option) => idSet.has(option.id));
  });
}

/**
 * Merges base filter config with current selection and options.
 * @param base Base filter definition
 * @param selected Current selected options accessor
 * @param options Optional dynamic options accessor
 */
function optionsToFilter<Opt extends SearchListOption>(
  base: FilterOptionCategory<Opt>,
  selected: () => Opt[] | null,
  options?: () => Opt[],
): Signal<FilterOptionCategory<Opt>> {
  return computed(() => ({ ...base, options: options?.() ?? base.options, selected: selected() ?? [] }));
}

/**
 * Builds a set of selected option IDs across multiple selections.
 * @param options Accessors returning selected option arrays
 */
function optionsToSet<Opt extends string>(...options: (() => TypedSearchListOption<Opt>[] | null)[]): Signal<Set<Opt>> {
  return computed(() => new Set<Opt>(options.flatMap((opts) => opts()?.map((option) => option.id) ?? [])));
}

/**
 * Filters items by selected options using a provided predicate.
 * @param items Accessor for all items
 * @param options Accessor for selected option IDs
 * @param filterFn Predicate that checks item against selected options
 */
function createFilteredBy<T, Opt>(
  items: () => T[],
  options: () => Set<Opt>,
  filterFn: (item: T, options: Set<Opt>) => boolean,
): Signal<T[]> {
  return computed(() => {
    const selectedOptions = options();
    const allItems = items();
    if (selectedOptions.size === 0) {
      return allItems;
    }

    return allItems.filter((item) => filterFn(item, selectedOptions));
  });
}

/**
 * Normalizes search text for case/diacritic-insensitive matching.
 * @param str Raw input string
 */
function normalizeSearchString(str: string): string {
  return str
    .trim()
    .toLocaleLowerCase()
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '')
    .replace(/\s{2,}/, ' ');
}

/**
 * Converts a string to sentence case.
 * @param str Raw input string
 */
function toSentenceCase(str: string): string {
  return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase();
}

/**
 * Counts occurrences of keys derived from items.
 * @param items Accessor for items to count
 * @param keyFn Key selector returning one or many keys per item
 */
function countsByKey(
  items: () => ResearchItem[],
  keyFn: (item: ResearchItem) => string | string[],
  filterFn?: (item: ResearchItem) => boolean,
): Signal<Record<string, number>> {
  return computed(() => {
    const counts: Record<string, number> = {};
    for (const item of items()) {
      if (filterFn?.(item) === false) {
        continue;
      }

      const keys = keyFn(item);
      for (const key of Array.isArray(keys) ? keys : [keys]) {
        counts[key] ??= 0;
        counts[key] += 1;
      }
    }
    return counts;
  });
}

/**
 * Adds filtering capabilities for research items.
 * Provides filtered item lists, counts, and filter option signals.
 */
export function withFilters() {
  return signalStoreFeature(
    { state: type<ResearchState>() },
    withState(initialState),
    withComputed((store) => {
      const _peopleOptions = computed(() =>
        store
          .peopleItems()
          .map((person) => ({ id: person.slug, label: person.name }))
          .sort((a, b) => a.label.localeCompare(b.label)),
      );
      const people = filterOptionsByIds(_peopleOptions, store.peopleIds);

      const _publicationOptions = researchTypesToOptions(store.pubTypes);
      const publications = filterOptionsByIds(_publicationOptions, store.publicationIds);

      const _eventOptions = researchTypesToOptions(store.eventTypes);
      const events = filterOptionsByIds(_eventOptions, store.eventIds);

      const _fundingOptions = researchTypesToOptions(store.fundingTypes);
      const funding = filterOptionsByIds(_fundingOptions, store.fundingIds);

      const _categoriesFilter = optionsToFilter(CATEGORIES_FILTER, store.categories);
      const _eventsFilter = optionsToFilter(EVENTS_FILTER, events, _eventOptions);
      const _fundingFilter = optionsToFilter(FUNDING_FILTER, funding, _fundingOptions);
      const _publicationsFilter = optionsToFilter(PUBLICATIONS_FILTER, publications, _publicationOptions);
      const _peopleFilter = optionsToFilter(PEOPLE_FILTER, people, _peopleOptions);
      const _yearsFilter = optionsToFilter(YEARS_FILTER, store.years);

      const filters = computed((): FilterOptionCategory<SearchListOption>[] => [
        _categoriesFilter(),
        _eventsFilter(),
        _fundingFilter(),
        _publicationsFilter(),
        _peopleFilter(),
        _yearsFilter(),
      ]);

      const _selectedCategories = optionsToSet(store.categories);
      const _filteredByCategory = createFilteredBy(
        store.researchItems,
        _selectedCategories,
        (item, selectedCategories) => selectedCategories.has(item.category),
      );

      const _selectedTypes = optionsToSet(funding, publications, events);
      const _filteredByType = createFilteredBy(_filteredByCategory, _selectedTypes, (item, selectedTypes) =>
        selectedTypes.has(item.type),
      );

      const _selectedPeople = optionsToSet(people);
      const _filteredByPeople = createFilteredBy(_filteredByType, _selectedPeople, (item, selectedPeople) =>
        item.people.some((person) => selectedPeople.has(person)),
      );

      const _selectedYears = computed(() => new Set(store.years()?.map((option) => option.year) ?? []));
      const _filteredByYear = createFilteredBy(_filteredByPeople, _selectedYears, (item, selectedYears) =>
        selectedYears.has(item.dateStart.getFullYear()),
      );

      const _filteredBySearch = computed(() => {
        const search = store.search()?.trim();
        const items = _filteredByYear();
        if (!search) {
          return items;
        }

        const normalizedSearch = normalizeSearchString(search);
        return items.filter(
          (item) =>
            normalizeSearchString(item.title).includes(normalizedSearch) ||
            normalizeSearchString(item.description).includes(normalizedSearch),
        );
      });

      const countsByCategory = countsByKey(store.researchItems, (item) => item.category);
      const countsByEventType = countsByKey(
        store.researchItems,
        (item) => item.type,
        (item) => item.category === 'event',
      );
      const countsByFundingType = countsByKey(
        store.researchItems,
        (item) => item.type,
        (item) => item.category === 'funding',
      );
      const countsByPublicationType = countsByKey(
        store.researchItems,
        (item) => item.type,
        (item) => item.category === 'publication',
      );
      const countsByPeople = countsByKey(store.researchItems, (item) => item.people);
      const countsByYear = countsByKey(store.researchItems, (item) => item.dateStart.getFullYear().toString());

      const counts = computed(() => [
        countsByCategory(),
        countsByEventType(),
        countsByFundingType(),
        countsByPublicationType(),
        countsByPeople(),
        countsByYear(),
      ]);

      return {
        people,
        filters,
        filteredItems: _filteredBySearch,
        numFilteredItems: computed(() => _filteredBySearch().length),
        countsByCategory,
        countsByEventType,
        countsByFundingType,
        countsByPublicationType,
        countsByPeople,
        countsByYear,
        counts,
        _filteredByCategory,
        _filteredByType,
        _filteredByPeople,
        _filteredByYear,
      } satisfies FilterProps & InternalProps;
    }),
    withMethods((store) => ({
      /** Sets selected categories */
      setCategories: signalMethod((categories: CategoryOption[] | null) => patchState(store, { categories })),
      /** Sets selected events */
      setEventIds: signalMethod((eventIds: string[] | null) => patchState(store, { eventIds })),
      /** Sets selected funding IDs */
      setFundingIds: signalMethod((fundingIds: string[] | null) => patchState(store, { fundingIds })),
      /** Sets selected publication IDs */
      setPublicationIds: signalMethod((publicationIds: string[] | null) => patchState(store, { publicationIds })),
      /** Sets selected people IDs */
      setPeopleIds: signalMethod((peopleIds: string[] | null) => patchState(store, { peopleIds })),
      /**
       * Sets selected people options.
       * @param people Selected people options
       */
      setPeople: signalMethod((people: PeopleOption[] | null) =>
        patchState(store, { peopleIds: people?.map((p) => p.id) ?? null }),
      ),
      /**
       * Sets selected years.
       * @param years Selected year options
       */
      setYears: signalMethod((years: YearOption[] | null) => patchState(store, { years })),
      /**
       * Sets search text.
       * @param search Search string
       */
      setSearch: signalMethod((search: string | null) => patchState(store, { search })),
      /**
       * Updates all filters from filter menu selections.
       * @param filters Filter menu categories with selections
       */
      updateFilters: signalMethod((filters: FilterOptionCategory<SearchListOption>[]) => {
        const categories = filters[0]?.selected as CategoryOption[];
        const events = filters[1]?.selected as EventOption[];
        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[];

        patchState(store, {
          categories: categories.length > 0 ? categories : null,
          publicationIds: publications.length > 0 ? publications.map((p) => p.id) : null,
          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,
          years: years.length > 0 ? years : null,
        });
      }),
      /** Resets all filters to defaults */
      resetFilters: () => patchState(store, initialState),
    })),
  );
}

results matching ""

    No results matching ""