Skip to content
Snippets Groups Projects
Commit 5a7453fa authored by Miłosz Grocholewski's avatar Miłosz Grocholewski
Browse files

Merge branch 'feat/MIN-119-complexes-in-semantic-view' into 'development'

feat(vector-map): treating complexes in the same way as compartments

Closes MIN-119

See merge request !338
parents e0cafd6d 14aee284
No related branches found
No related tags found
1 merge request!338feat(vector-map): treating complexes in the same way as compartments
Pipeline #99327 passed
Showing
with 317 additions and 86 deletions
......@@ -54,8 +54,7 @@ export const onMapLeftClick =
const featureZIndex = feature.get('zIndex');
if (
(isFeatureFilledCompartment(feature) || isFeatureNotCompartment(feature)) &&
(featureZIndex === undefined || featureZIndex >= 0) &&
!feature.get('hidden')
(featureZIndex === undefined || featureZIndex >= 0)
) {
featureAtPixel = feature;
return true;
......
......@@ -40,7 +40,7 @@ export const onMapRightClick =
FEATURE_TYPE.REACTION,
FEATURE_TYPE.GLYPH
].includes(feature.get('type'))
) && feature.get('zIndex') >= 0 && !feature.get('hidden');
) && feature.get('zIndex') >= 0;
});
}
}
......
......@@ -67,7 +67,10 @@ export const useOlMapReactionsLayer = ({
const overlaysOrder = useSelector(getOverlayOrderSelector);
const mapBackgroundType = useSelector(mapBackgroundTypeSelector);
const currentMarkers = useAppSelector(markersSufraceOfCurrentMapDataSelector);
const markersRender = parseSurfaceMarkersToBioEntityRender(currentMarkers);
const markersRender = useMemo(() => {
return parseSurfaceMarkersToBioEntityRender(currentMarkers);
}, [currentMarkers]);
const bioEntities = useAppSelector(overlayBioEntitiesForCurrentModelSelector);
const reactionsForCurrentModel = useAppSelector(newReactionsForCurrentModelSelector);
const modelElementsLoading = useAppSelector(modelElementsLoadingSelector);
......
......@@ -26,6 +26,7 @@ import { Extent } from 'ol/extent';
import { MapSize } from '@/redux/map/map.types';
import getCoverStyles from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getCoverStyles';
import handleSemanticView from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/handleSemanticView';
import getScaledStrokeStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledStrokeStyle';
export interface BaseMapElementProps {
type: string;
......@@ -266,20 +267,19 @@ export default abstract class BaseMultiPolygon {
!COMPLEX_SBO_TERMS.includes(this.sboTerm) &&
scale < COMPLEX_CONTENTS_CUTOFF_SCALE
) {
feature.set('hidden', true);
return [];
}
feature.set('hidden', false);
let hide = false;
if (this.mapBackgroundType === MapBackgroundsEnum.SEMANTIC && scale < TEXT_CUTOFF_SCALE) {
const semanticViewData = handleSemanticView(
this.vectorSource,
const semanticViewData = handleSemanticView({
vectorSource: this.vectorSource,
feature,
resolution,
this.compartmentId,
this.complexId,
);
sboTerm: this.sboTerm,
compartmentId: this.compartmentId,
complexId: this.complexId,
});
cover = semanticViewData.cover;
hide = semanticViewData.hide;
largestExtent = semanticViewData.largestExtent;
......@@ -304,14 +304,15 @@ export default abstract class BaseMultiPolygon {
if (cover) {
if (coverStyle && largestExtent) {
styles.push(
...getCoverStyles(
...getCoverStyles({
coverStyle,
largestExtent,
this.text,
text: this.text,
scale,
this.zIndex + 100000,
this.mapSize,
),
zIndex: this.zIndex + 100000,
mapSize: this.mapSize,
strokeStyle,
}),
);
}
return;
......@@ -329,7 +330,6 @@ export default abstract class BaseMultiPolygon {
textStyle.setScale(scale);
}
if (strokeStyle) {
const lineWidth = strokeStyle.getWidth() || 1;
if (
!this.overlaysVisible &&
scale < OUTLINE_CUTOFF_SCALE &&
......@@ -338,18 +338,7 @@ export default abstract class BaseMultiPolygon {
) {
style.setStroke(null);
} else {
const lineDash = strokeStyle.getLineDash();
let newLineDash: Array<number> = [];
if (lineDash) {
newLineDash = lineDash.map(width => width * scale);
}
const newStrokeStyle = new Stroke({
color: strokeStyle.getColor(),
width: lineWidth * scale,
lineDash: newLineDash,
});
style.setStroke(newStrokeStyle);
style.setStroke(getScaledStrokeStyle(strokeStyle, scale));
}
}
styles.push(style);
......
......@@ -13,6 +13,7 @@ import {
} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types';
import {
BLACK_COLOR,
COMPLEX_SBO_TERMS,
MAP_ELEMENT_TYPES,
TRANSPARENT_COLOR,
WHITE_COLOR,
......@@ -311,14 +312,20 @@ export default class MapElement extends BaseMultiPolygon {
lineDash: this.lineDash,
zIndex: this.zIndex,
});
elementPolygon.set(
'strokeStyle',
getStroke({
color: rgbToHex(this.borderColor),
width: this.lineWidth,
lineDash: this.lineDash,
}),
);
const strokeStyle = getStroke({
color: rgbToHex(this.borderColor),
width: this.lineWidth,
lineDash: this.lineDash,
});
elementPolygon.set('strokeStyle', strokeStyle);
if (COMPLEX_SBO_TERMS.includes(this.sboTerm)) {
const coverStyle = new Style({
geometry: elementPolygon,
fill: getFill({ color: rgbToHex({ ...this.fillColor, alpha: 255 }) }),
stroke: strokeStyle,
});
elementPolygon.set('coverStyle', coverStyle);
}
this.polygons.push(elementPolygon);
this.styles.push(elementStyle);
});
......
......@@ -24,6 +24,7 @@ describe('handleSemanticView', () => {
filled: false,
hidden: false,
});
feature.setId(1);
const mockGeometry = {
getExtent: jest.fn(() => [2, 0, 10, 10]),
......@@ -38,6 +39,7 @@ describe('handleSemanticView', () => {
.mockImplementation((_, callback) => {
callback(
new Feature({
id: 1,
geometry: fromExtent([1, 0, 5, 5]),
hidden: false,
type: 'COMPARTMENT',
......@@ -50,7 +52,13 @@ describe('handleSemanticView', () => {
(getDividedExtents as jest.Mock).mockReturnValue([[0, 0, 10, 5]]);
(findLargestExtent as jest.Mock).mockReturnValue([0, 0, 10, 5]);
const result = handleSemanticView(vectorSource, feature, 1, null);
const result = handleSemanticView({
vectorSource,
feature,
resolution: 1,
sboTerm: 'SBO:123456',
compartmentId: 2,
});
expect(result).toEqual({
cover: true,
......@@ -63,21 +71,26 @@ describe('handleSemanticView', () => {
expect(findLargestExtent).toHaveBeenCalled();
});
it('should return hide = true when complexId points to a hidden feature', () => {
const complexFeature = new Feature({ hidden: true });
it('should return hide = true when complexId points to a filled feature', () => {
const complexFeature = new Feature({ filled: true });
jest
.spyOn(vectorSource, 'getFeatureById')
.mockImplementation(id => (id === 1 ? complexFeature : null));
const result = handleSemanticView(vectorSource, feature, 1, null, 1);
const result = handleSemanticView({
vectorSource,
feature,
resolution: 1,
sboTerm: 'SBO:123456',
compartmentId: null,
complexId: 1,
});
expect(result).toEqual({
cover: true,
hide: true,
largestExtent: [0, 0, 10, 5],
});
expect(feature.get('hidden')).toBe(true);
});
it('should return hide = true when compartmentId points to a filled feature', () => {
......@@ -86,14 +99,18 @@ describe('handleSemanticView', () => {
.spyOn(vectorSource, 'getFeatureById')
.mockImplementation(id => (id === 2 ? compartmentFeature : null));
const result = handleSemanticView(vectorSource, feature, 1, 2);
const result = handleSemanticView({
vectorSource,
feature,
resolution: 1,
sboTerm: 'SBO:123456',
compartmentId: 2,
});
expect(result).toEqual({
cover: true,
hide: true,
largestExtent: [0, 0, 10, 5],
});
expect(feature.get('hidden')).toBe(true);
});
});
......@@ -4,22 +4,41 @@ import findLargestExtent from '@/components/Map/MapViewer/MapViewerVector/utils/
import Feature from 'ol/Feature';
import VectorSource from 'ol/source/Vector';
import { Extent } from 'ol/extent';
import { MAP_ELEMENT_TYPES } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants';
import {
COMPLEX_SBO_TERMS,
MAP_ELEMENT_TYPES,
} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants';
import isFeatureInCompartment from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/isFeatureInCompartment';
export default function handleSemanticView(
vectorSource: VectorSource,
feature: Feature,
resolution: number,
compartmentId: number | null,
complexId?: number | null,
): { cover: boolean; hide: boolean; largestExtent: Extent | null } {
export default function handleSemanticView({
vectorSource,
feature,
resolution,
sboTerm,
compartmentId,
complexId,
}: {
vectorSource: VectorSource;
feature: Feature;
resolution: number;
sboTerm: string;
compartmentId: number | null;
complexId?: number | null;
}): { cover: boolean; hide: boolean; largestExtent: Extent | null } {
const featureId = feature.getId();
if (!featureId) {
return { cover: false, hide: true, largestExtent: null };
}
const type = feature.get('type');
const getMapExtent = feature.get('getMapExtent');
let coverRatio = 1;
let cover = false;
let hide = false;
let largestExtent: Extent | null = null;
if (getMapExtent instanceof Function && type === MAP_ELEMENT_TYPES.COMPARTMENT) {
if (
getMapExtent instanceof Function &&
(type === MAP_ELEMENT_TYPES.COMPARTMENT || COMPLEX_SBO_TERMS.includes(sboTerm))
) {
const mapExtent = getMapExtent(resolution);
const featureExtent = feature.getGeometry()?.getExtent();
if (featureExtent && mapExtent) {
......@@ -33,10 +52,10 @@ export default function handleSemanticView(
let remainingExtents = [featureExtent];
vectorSource.forEachFeatureIntersectingExtent(featureExtent, intersectingFeature => {
if (
!intersectingFeature.get('hidden') &&
intersectingFeature.get('type') === MAP_ELEMENT_TYPES.COMPARTMENT &&
intersectingFeature.get('zIndex') > feature.get('zIndex') &&
intersectingFeature.get('filled')
intersectingFeature.get('filled') &&
!isFeatureInCompartment(+featureId, vectorSource, intersectingFeature)
) {
const intersectingFeatureExtent = intersectingFeature.getGeometry()?.getExtent();
if (intersectingFeatureExtent) {
......@@ -52,7 +71,7 @@ export default function handleSemanticView(
if (complexId) {
const complex = vectorSource.getFeatureById(complexId);
if (complex && complex.get('hidden')) {
if (complex && complex.get('filled')) {
hide = true;
}
}
......@@ -62,7 +81,6 @@ export default function handleSemanticView(
hide = true;
}
}
(feature as Feature).set('hidden', hide);
return { cover, hide, largestExtent };
}
/* eslint-disable no-magic-numbers */
import VectorSource from 'ol/source/Vector';
import Feature from 'ol/Feature';
import isFeatureInCompartment from './isFeatureInCompartment';
describe('isFeatureInCompartment', () => {
let mockVectorSource: jest.Mocked<VectorSource>;
beforeEach(() => {
mockVectorSource = {
getFeatureById: jest.fn(),
} as unknown as jest.Mocked<VectorSource>;
});
it('should return false if the feature has no compartmentId', () => {
const feature = new Feature();
feature.set('compartmentId', null);
const result = isFeatureInCompartment(1, mockVectorSource, feature);
expect(result).toBe(false);
});
it('should return true if the feature compartmentId matches parentCompartmentId', () => {
const feature = new Feature();
feature.set('compartmentId', 1);
const result = isFeatureInCompartment(1, mockVectorSource, feature);
expect(result).toBe(true);
});
it('should return false if the parent feature does not exist', () => {
const feature = new Feature();
feature.set('compartmentId', 2);
mockVectorSource.getFeatureById.mockReturnValueOnce(null);
const result = isFeatureInCompartment(1, mockVectorSource, feature);
expect(result).toBe(false);
expect(mockVectorSource.getFeatureById).toHaveBeenCalledWith(2);
});
it('should return true if parent feature matches parentCompartmentId in recursive call', () => {
const parentFeature = new Feature();
parentFeature.set('compartmentId', 1);
const childFeature = new Feature();
childFeature.set('compartmentId', 2);
mockVectorSource.getFeatureById.mockReturnValueOnce(parentFeature);
const result = isFeatureInCompartment(1, mockVectorSource, childFeature);
expect(result).toBe(true);
expect(mockVectorSource.getFeatureById).toHaveBeenCalledWith(2);
});
it('should return false if recursive call finds no matching parent', () => {
const grandParentFeature = new Feature();
grandParentFeature.set('compartmentId', 3);
const parentFeature = new Feature();
parentFeature.set('compartmentId', 2);
const childFeature = new Feature();
childFeature.set('compartmentId', 4);
mockVectorSource.getFeatureById
.mockReturnValueOnce(parentFeature)
.mockReturnValueOnce(grandParentFeature);
const result = isFeatureInCompartment(1, mockVectorSource, childFeature);
expect(result).toBe(false);
expect(mockVectorSource.getFeatureById).toHaveBeenCalledWith(4);
expect(mockVectorSource.getFeatureById).toHaveBeenCalledWith(2);
});
});
import VectorSource from 'ol/source/Vector';
import Feature from 'ol/Feature';
export default function isFeatureInCompartment(
parentCompartmentId: number,
vectorSource: VectorSource,
feature: Feature,
): boolean {
const compartmentId: undefined | null | number = feature.get('compartmentId');
if (!compartmentId) {
return false;
}
if (compartmentId === parentCompartmentId) {
return true;
}
const compartmentFeature = vectorSource.getFeatureById(compartmentId);
if (!compartmentFeature) {
return false;
}
return isFeatureInCompartment(parentCompartmentId, vectorSource, compartmentFeature);
}
......@@ -349,7 +349,15 @@ export default class Reaction {
protected isAnyOfElementsHidden(): boolean {
return [...this.products, ...this.reactants, ...this.modifiers].some(reactionElement => {
const feature = this.vectorSource.getFeatureById(reactionElement.element);
return feature && feature.get('hidden');
if (!feature) {
return false;
}
const complexId: undefined | number = feature.get('complexId');
const compartmentId: undefined | null | number = feature.get('compartmentId');
if (complexId && this.vectorSource.getFeatureById(complexId)?.get('filled')) {
return true;
}
return compartmentId && this.vectorSource.getFeatureById(compartmentId)?.get('filled');
});
}
......@@ -358,10 +366,8 @@ export default class Reaction {
return undefined;
}
if (this.isAnyOfElementsHidden()) {
feature.set('hidden', true);
return undefined;
}
feature.set('hidden', false);
const styles: Array<Style> = [];
const scale = this.minResolution / resolution;
......@@ -391,10 +397,8 @@ export default class Reaction {
return undefined;
}
if (this.isAnyOfElementsHidden()) {
feature.set('hidden', true);
return undefined;
}
feature.set('hidden', false);
const styles: Array<Style> = [];
const style = feature.get('style');
......
......@@ -39,7 +39,7 @@ describe('getCoverStyles', () => {
const mockTextStyle = new Style();
(getTextStyle as jest.Mock).mockReturnValue(mockTextStyle);
const result = getCoverStyles(coverStyle, largestExtent, text, scale, zIndex, mapSize);
const result = getCoverStyles({ coverStyle, largestExtent, text, scale, zIndex, mapSize });
expect(result).toHaveLength(2);
expect(result[0]).toBe(coverStyle);
......@@ -80,7 +80,7 @@ describe('getCoverStyles', () => {
fontSize: 0,
});
const result = getCoverStyles(coverStyle, largestExtent, text, scale, zIndex, mapSize);
const result = getCoverStyles({ coverStyle, largestExtent, text, scale, zIndex, mapSize });
expect(result).toHaveLength(1);
expect(result[0]).toBe(coverStyle);
......
......@@ -7,17 +7,33 @@ import getWrappedTextWithFontSize from '@/components/Map/MapViewer/MapViewerVect
import { Point } from 'ol/geom';
import getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle';
import { MapSize } from '@/redux/map/map.types';
import { Stroke } from 'ol/style';
import getScaledStrokeStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledStrokeStyle';
export default function getCoverStyles(
coverStyle: Style,
largestExtent: Extent,
text: string,
scale: number,
zIndex: number,
mapSize: MapSize,
): Array<Style> {
export default function getCoverStyles({
coverStyle,
largestExtent,
text,
scale,
zIndex,
mapSize,
strokeStyle,
}: {
coverStyle: Style;
largestExtent: Extent;
text: string;
scale: number;
zIndex: number;
mapSize: MapSize;
strokeStyle?: Stroke;
}): Array<Style> {
const styles: Array<Style> = [];
coverStyle.setZIndex(zIndex);
if (coverStyle.getStroke() && strokeStyle) {
coverStyle.setStroke(getScaledStrokeStyle(strokeStyle, scale));
}
styles.push(coverStyle);
if (text) {
......
/* eslint-disable no-magic-numbers */
import Style from 'ol/style/Style';
import { Stroke, Text } from 'ol/style';
import getScaledStrokeStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledStrokeStyle';
import getScaledElementStyle from './getScaledElementStyle';
jest.mock('./getScaledStrokeStyle');
describe('getScaledElementStyle', () => {
it('should scale the stroke style and text scale when strokeStyle is provided', () => {
const mockScaledStroke = new Stroke({ color: 'blue', width: 4 });
(getScaledStrokeStyle as jest.Mock).mockReturnValue(mockScaledStroke);
const strokeStyle = new Stroke({ color: 'red', width: 2 });
const textStyle = new Text({ text: 'Test', scale: 1 });
const style = new Style({ stroke: strokeStyle, text: textStyle });
const scale = 2;
const scaledStyle = getScaledElementStyle(style, strokeStyle, scale);
expect(getScaledStrokeStyle).toHaveBeenCalledWith(strokeStyle, scale);
expect(scaledStyle.getStroke()).toBe(mockScaledStroke);
expect(scaledStyle.getText()?.getScale()).toBe(2);
});
it('should scale only text when strokeStyle is not provided', () => {
const textStyle = new Text({ text: 'Test', scale: 1 });
const style = new Style({ text: textStyle });
const scale = 3;
const scaledStyle = getScaledElementStyle(style, undefined, scale);
expect(scaledStyle.getStroke()).toBeNull();
expect(scaledStyle.getText()?.getScale()).toBe(3);
});
});
/* eslint-disable no-magic-numbers */
import Style from 'ol/style/Style';
import { Stroke } from 'ol/style';
import getScaledStrokeStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledStrokeStyle';
export default function getScaledElementStyle(
style: Style,
......@@ -8,18 +9,7 @@ export default function getScaledElementStyle(
scale: number,
): Style {
if (strokeStyle) {
const lineDash = strokeStyle.getLineDash();
let newLineDash: Array<number> = [];
if (lineDash) {
newLineDash = lineDash.map(width => width * scale);
}
const newStrokeStyle = new Stroke({
color: strokeStyle.getColor(),
width: (strokeStyle.getWidth() || 1) * scale,
lineDash: newLineDash,
});
style.setStroke(newStrokeStyle);
style.setStroke(getScaledStrokeStyle(strokeStyle, scale));
}
style.getText()?.setScale(scale);
return style;
......
/* eslint-disable no-magic-numbers */
import { Stroke } from 'ol/style';
import getScaledStrokeStyle from './getScaledStrokeStyle';
describe('getScaledStrokeStyle', () => {
it('should correctly scale the width and lineDash array of a Stroke style', () => {
const strokeStyle = new Stroke({
color: 'red',
width: 2,
lineDash: [4, 8],
});
const scale = 0.2;
const scaledStroke = getScaledStrokeStyle(strokeStyle, scale);
expect(scaledStroke.getColor()).toBe('red');
expect(scaledStroke.getWidth()).toBe(0.4);
expect(scaledStroke.getLineDash()).toEqual([0.8, 1.6]);
});
it('should use a default width of 1 and scale it correctly when getWidth() returns undefined', () => {
const strokeStyle = new Stroke({
color: 'green',
lineDash: [2, 3],
});
jest.spyOn(strokeStyle, 'getWidth').mockReturnValue(undefined);
const scale = 3;
const scaledStroke = getScaledStrokeStyle(strokeStyle, scale);
expect(scaledStroke.getColor()).toBe('green');
expect(scaledStroke.getWidth()).toBe(3);
expect(scaledStroke.getLineDash()).toEqual([6, 9]);
});
});
/* eslint-disable no-magic-numbers */
import { Stroke } from 'ol/style';
export default function getScaledStrokeStyle(strokeStyle: Stroke, scale: number): Stroke {
const lineWidth = strokeStyle.getWidth() || 1;
const lineDash = strokeStyle.getLineDash();
let newLineDash: Array<number> = [];
if (lineDash) {
newLineDash = lineDash.map(width => width * scale);
}
return new Stroke({
color: strokeStyle.getColor(),
width: lineWidth * scale,
lineDash: newLineDash,
});
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment