import { Computed, DataAction, StateRepository } from '@angular-ru/ngxs/decorators';
import { NgxsImmutableDataRepository } from '@angular-ru/ngxs/repositories';
import { Injectable, Injector } from '@angular/core';
import { State } from '@ngxs/store';
import { ALL_ORGANS, GlobalConfigState, OrganInfo } from 'ccf-shared';
import { filterNulls } from 'ccf-shared/rxjs-ext/operators';
import { sortBy } from 'lodash';
import { GoogleAnalyticsService } from 'ngx-google-analytics';
import { EMPTY, Observable } from 'rxjs';
import {
delay,
distinct,
distinctUntilChanged,
filter,
map,
skipUntil,
switchMap,
tap,
throttleTime,
} from 'rxjs/operators';
import { ExtractionSet } from '../../models/extraction-set';
import { VisibilityItem } from '../../models/visibility-item';
import { GlobalConfig, OrganConfig } from '../../services/config/config';
import { PageState } from '../page/page.state';
import { ReferenceDataState } from '../reference-data/reference-data.state';
export interface XYZTriplet<T = number> {
x: T;
y: T;
z: T;
}
export interface SlicesConfig {
thickness: number;
numSlices: number;
}
export type ViewType = 'register' | '3d';
export type ViewSide = 'left' | 'right' | 'anterior' | 'posterior';
export interface ModelStateModel {
id: string;
label: string;
organ: OrganInfo;
organIri?: string;
organDimensions: XYZTriplet;
sex?: 'male' | 'female';
side?: 'left' | 'right';
blockSize: XYZTriplet;
rotation: XYZTriplet;
position: XYZTriplet;
slicesConfig: SlicesConfig;
viewType: ViewType;
viewSide: ViewSide;
showPrevious: boolean;
extractionSites: VisibilityItem[];
anatomicalStructures: VisibilityItem[];
extractionSets: ExtractionSet[];
}
export const RUI_ORGANS = ALL_ORGANS;
@StateRepository()
@State<ModelStateModel>({
name: 'model',
defaults: {
id: '',
label: '',
organ: { src: '', name: '' } as OrganInfo,
organIri: '',
organDimensions: { x: 90, y: 90, z: 90 },
sex: 'male',
blockSize: { x: 10, y: 10, z: 10 },
rotation: { x: 0, y: 0, z: 0 },
position: { x: 0, y: 0, z: 0 },
slicesConfig: { thickness: NaN, numSlices: NaN },
viewType: 'register',
viewSide: 'anterior',
showPrevious: false,
extractionSites: [],
anatomicalStructures: [],
extractionSets: [],
},
})
@Injectable()
export class ModelState extends NgxsImmutableDataRepository<ModelStateModel> {
readonly id$ = this.state$.pipe(
map((x) => x?.id),
distinct(),
);
readonly blockSize$ = this.state$.pipe(
map((x) => x?.blockSize),
distinct(),
);
readonly rotation$ = this.state$.pipe(
map((x) => x?.rotation),
distinct(),
);
readonly position$ = this.state$.pipe(
map((x) => x?.position),
distinct(),
);
readonly slicesConfig$ = this.state$.pipe(
map((x) => x?.slicesConfig),
distinct(),
);
readonly viewType$ = this.state$.pipe(
map((x) => x?.viewType),
distinct(),
);
readonly viewSide$ = this.state$.pipe(
map((x) => x?.viewSide),
distinct(),
);
readonly organ$ = this.state$.pipe(
map((x) => x?.organ),
distinct(),
);
readonly organIri$ = this.state$.pipe(
map((x) => x?.organIri),
distinct(),
);
readonly organDimensions$ = this.state$.pipe(
map((x) => x?.organDimensions),
distinct(),
);
readonly sex$ = this.state$.pipe(
map((x) => x?.sex),
distinct(),
);
readonly side$ = this.state$.pipe(
map((x) => x?.side),
distinct(),
);
readonly showPrevious$ = this.state$.pipe(
map((x) => x?.showPrevious),
distinct(),
);
readonly extractionSites$ = this.state$.pipe(
map((x) => x?.extractionSites),
distinct(),
);
readonly anatomicalStructures$ = this.state$.pipe(
map((x) => x?.anatomicalStructures),
distinct(),
);
readonly extractionSets$ = this.state$.pipe(
map((x) => x?.extractionSets),
distinct(),
);
@Computed()
get modelChanged$(): Observable<void> {
const ignoredKeys = ['viewType', 'viewSide', 'showPrevious'];
const keys = Object.keys(this.initialState).filter((key) => !ignoredKeys.includes(key));
return this.state$.pipe(
throttleTime(0, undefined, { leading: false, trailing: true }),
distinctUntilChanged((v1, v2) => {
for (const key of keys) {
if (v1[key as never] !== v2[key as never]) {
return false;
}
}
return true;
}),
map(() => undefined),
);
}
private referenceData!: ReferenceDataState;
private page!: PageState;
constructor(
private readonly ga: GoogleAnalyticsService,
private readonly injector: Injector,
private readonly globalConfig: GlobalConfigState<GlobalConfig>,
) {
super();
}
override ngxsOnInit(): void {
super.ngxsOnInit();
this.referenceData = this.injector.get(ReferenceDataState);
this.page = this.injector.get(PageState);
this.referenceData.state$.subscribe(() => this.onReferenceDataChange());
}
idMatches(ontologyId?: string, organSide?: string): OrganInfo | undefined {
return ALL_ORGANS.find((o) => (ontologyId && o.id === ontologyId ? (o.side ? o.side === organSide : true) : false));
}
nameMatches(organName: string, organSide?: string): OrganInfo | undefined {
return ALL_ORGANS.find((o) =>
o.side ? o.organ.toLowerCase() === organName && o.side === organSide : o.organ.toLowerCase() === organName,
);
}
@DataAction()
setBlockSize(blockSize: XYZTriplet): void {
this.ctx.patchState({ blockSize });
}
@DataAction()
setRotation(rotation: XYZTriplet): void {
this.ctx.patchState({ rotation });
}
@DataAction()
setPosition(position: XYZTriplet): void {
this.ga.event(
'placement',
`${this.snapshot.organ?.name}_placement`,
`${position.x.toFixed(1)}_${position.y.toFixed(1)}_${position.z.toFixed(1)}`,
);
this.ctx.patchState({ position });
}
@DataAction()
setSlicesConfig(slicesConfig: SlicesConfig): void {
this.ctx.patchState({ slicesConfig });
}
@DataAction()
setViewType(viewType: ViewType): void {
this.ctx.patchState({ viewType });
}
@DataAction()
setViewSide(viewSide: ViewSide): void {
this.ctx.patchState({ viewSide });
}
@Computed()
get defaultPosition(): XYZTriplet {
const dims = this.snapshot.organDimensions;
const block = this.snapshot.blockSize;
return { x: dims.x + 2 * block.x, y: dims.y / 2, z: dims.z / 2 };
}
@DataAction()
setOrgan(organ: OrganInfo): void {
if (organ) {
this.ga.event('organ_select', 'organ', organ.name);
this.ctx.patchState({ organ });
if (organ.side) {
this.ctx.patchState({ side: organ.side });
} else {
this.ctx.patchState({ side: undefined });
}
this.onOrganIriChange();
}
}
@DataAction()
setOrganDefaults(): void {
this.ctx.patchState({
position: this.defaultPosition,
rotation: { x: 0, y: 0, z: 0 },
});
}
@DataAction()
setSex(sex?: 'male' | 'female'): void {
this.ctx.patchState({ sex });
this.onOrganIriChange();
}
@DataAction()
setSide(side?: 'left' | 'right'): void {
this.ctx.patchState({ side });
this.onOrganIriChange();
}
@DataAction()
setShowPrevious(showPrevious: boolean): void {
this.ctx.patchState({ showPrevious });
}
@DataAction()
setExtractionSites(extractionSites: VisibilityItem[]): void {
this.ctx.patchState({ extractionSites });
}
@DataAction()
setAnatomicalStructures(anatomicalStructures: VisibilityItem[]): void {
this.ctx.patchState({ anatomicalStructures });
}
@DataAction()
setExtractionSets(extractionSets: ExtractionSet[]): void {
this.ctx.patchState({ extractionSets });
}
toggleRegistrationBlocksVisibility(visible: boolean, previousItems: VisibilityItem[]): void {
this.setShowPrevious(visible);
if (!visible) {
this.setAnatomicalStructures(previousItems);
} else {
const newStructures = previousItems.map((structure) => ({
...structure,
opacity: Math.min(20, structure.opacity ?? 20),
}));
this.setAnatomicalStructures(newStructures);
}
}
private onOrganIriChange(): void {
const organIri = this.referenceData.getReferenceOrganIri(
this.snapshot.organ?.organ || '',
this.snapshot.sex,
this.snapshot.side,
this.snapshot.organ,
);
const organDimensions: XYZTriplet = { x: 100, y: 100, z: 100 };
if (this.snapshot.organ?.sex) {
this.ctx.patchState({ sex: this.snapshot.organ?.sex });
}
if (organIri) {
const db = this.referenceData.snapshot;
const asLookup: { [id: string]: VisibilityItem } = {};
for (const entity of db.anatomicalStructures[organIri] || []) {
const iri = entity.representation_of ?? entity['@id'];
if (!asLookup[iri]) {
asLookup[iri] = {
id: entity.representation_of ?? entity['@id'],
name: entity.label ?? '',
visible: true,
opacity: 20,
tooltip: entity.comment,
};
}
}
this.ctx.patchState({
anatomicalStructures: [
{ id: 'all', name: 'all anatomical structures', opacity: 20, visible: true },
...Object.values(asLookup),
],
});
const sets: ExtractionSet[] = (db.extractionSets[organIri] || []).map((set) => ({
name: set.label,
sites: [{ id: 'all', name: 'all landmarks', visible: true, opacity: 0 }].concat(
sortBy(
set.extractionSites.map((entity) => ({
id: entity['@id'],
name: entity.label ?? '',
visible: true,
opacity: 0,
tooltip: entity.comment,
})),
'name',
),
),
}));
this.ctx.patchState({ extractionSets: sets });
this.ctx.patchState({ extractionSites: sets.length > 0 ? sets[0].sites : [] });
const spatialEntity = db.organSpatialEntities[organIri];
organDimensions.x = spatialEntity.x_dimension;
organDimensions.y = spatialEntity.y_dimension;
organDimensions.z = spatialEntity.z_dimension;
}
this.ctx.patchState({ organIri, organDimensions });
}
private onReferenceDataChange(): void {
this.globalConfig
.getOption('organ')
.pipe(
filterNulls(),
delay(0),
switchMap((organ) => this.onOrganChange(organ)),
)
.subscribe();
this.modelChanged$
.pipe(skipUntil(this.page.registrationStarted$.pipe(filter((started) => started))))
.subscribe(() => this.page.setHasChanges());
}
private onOrganChange(organ: string | OrganConfig): Observable<unknown> {
let organInfo: OrganInfo | undefined;
let organSex: 'male' | 'female';
if (typeof organ === 'string') {
const organData = this.referenceData.getOrganData(organ);
organSex = organData?.sex?.toLowerCase() as 'male' | 'female';
organInfo = organData?.organ;
} else {
const organName = organ.name.toLowerCase();
const organSide = organ.side;
const ontologyId = organ.ontologyId;
organSex = organ.sex?.toLowerCase() as 'male' | 'female';
organInfo = this.idMatches(ontologyId, organSide);
if (!organInfo) {
organInfo = this.nameMatches(organName, organSide);
}
}
if (organInfo) {
this.ctx.patchState({
organ: organInfo,
sex: organSex,
side: organInfo?.side?.toLowerCase() as 'left' | 'right',
});
return this.referenceData.state$.pipe(tap(() => this.onOrganIriChange()));
}
return EMPTY;
}
}