Skip to content
Snippets Groups Projects
Commit fcd1b12f authored by Adrian Orłów's avatar Adrian Orłów :fire:
Browse files

Merge branch 'feature/MIN-78-display-pins-map-interactive-elements' into 'development'

feat(map): display pins map interactive elements

Closes MIN-78

See merge request !58
parents c33dca6f 4c815ebf
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!58feat(map): display pins map interactive elements
Pipeline #81759 passed
Showing
with 497 additions and 59 deletions
......@@ -62,6 +62,7 @@
"eslint-plugin-testing-library": "^6.0.1",
"husky": "^8.0.0",
"jest": "^29.7.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-watch-typeahead": "^2.2.2",
......@@ -4030,6 +4031,12 @@
"node": ">=4"
}
},
"node_modules/cssfontparser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz",
"integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==",
"dev": true
},
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
......@@ -7674,6 +7681,16 @@
}
}
},
"node_modules/jest-canvas-mock": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz",
"integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==",
"dev": true,
"dependencies": {
"cssfontparser": "^1.2.1",
"moo-color": "^1.0.2"
}
},
"node_modules/jest-changed-files": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz",
......@@ -9771,6 +9788,15 @@
"node": ">=10"
}
},
"node_modules/moo-color": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz",
"integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==",
"dev": true,
"dependencies": {
"color-name": "^1.1.4"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
......
import '@testing-library/jest-dom';
import 'jest-canvas-mock';
// used by openlayers module
global.ResizeObserver = jest.fn().mockImplementation(() => ({
......
/* eslint-disable no-magic-numbers */
import { DEFAULT_FONT_FAMILY } from '@/constants/font';
import { createCanvas } from '@/utils/canvas/getCanvas';
import {
drawNumberOnCanvas,
drawPinOnCanvas,
getTextPosition,
getTextWidth,
} from './getCanvasIcon';
const getContext = (): CanvasRenderingContext2D => {
const canvas = createCanvas({ width: 100, height: 100 });
return canvas.getContext('2d') as CanvasRenderingContext2D;
};
const ONCE = 1;
describe('getCanvasIcon - util', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
describe('getTextWidth - subUtil', () => {
const cases: [number, number][] = [
[1, 6.25],
[7, 8.333],
[43, 12.5],
[105, 16.666],
];
it.each(cases)('on value=%s should return %s', (input, output) => {
expect(getTextWidth(input)).toBeCloseTo(output);
});
});
describe('getTextPosition - subUtil', () => {
const cases: [number, number, number, number][] = [
[100, 100, -37.5, -27.2],
[532, 443, -253.5, -164.4],
[10, 0, 7.5, 12.8],
[0, 10, 12.5, 8.8],
[0, 0, 12.5, 12.8],
];
it.each(cases)(
'on textWidth=%s textHeight=%s should return x=%s y=%s',
(textWidth, textHeight, x, y) => {
expect(getTextPosition(textWidth, textHeight)).toMatchObject({
x,
y,
});
},
);
});
describe('drawPinOnCanvas - subUtil', () => {
const color = '#000000';
it('should run set fillStyle with color', () => {
const ctx = getContext();
drawPinOnCanvas({ color }, ctx);
expect(ctx.fillStyle).toBe(color);
});
it('should run fill method with valid arguments', () => {
const ctx = getContext();
const fillSpy = jest.spyOn(ctx, 'fill');
drawPinOnCanvas({ color }, ctx);
const call = fillSpy.mock.calls[0][0];
expect(call).toBeInstanceOf(Path2D);
expect(fillSpy).toBeCalledTimes(ONCE);
});
});
describe('drawNumberOnCanvas - subUtil', () => {
const ctx = getContext();
const fillTextSpy = jest.spyOn(ctx, 'fillText');
const value = 69;
beforeAll(() => {
drawNumberOnCanvas(
{
value,
},
ctx,
);
});
it('should set valid ctx fields', () => {
expect(ctx.fillStyle).toBe('#ffffff');
expect(ctx.textBaseline).toBe('top');
expect(ctx.font).toBe(`6.25px ${DEFAULT_FONT_FAMILY}`);
});
it('should run fillText once with valid args', () => {
expect(fillTextSpy).toBeCalledWith(`${value}`, 6.25, 12.8);
expect(fillTextSpy).toBeCalledTimes(ONCE);
});
});
});
import { PIN_PATH2D, PIN_SIZE } from '@/constants/canvas';
import { HALF, ONE_AND_HALF, QUARTER, THIRD, TWO_AND_HALF } from '@/constants/dividers';
import { DEFAULT_FONT_FAMILY } from '@/constants/font';
import { Point } from '@/types/map';
import { createCanvas } from '@/utils/canvas/getCanvas';
import { getFontSizeToFit } from '@/utils/canvas/getFontSizeToFit';
const SMALL_TEXT_VALUE = 1;
const MEDIUM_TEXT_VALUE = 10;
const BIG_TEXT_VALUE = 100;
interface Args {
color: string;
value: number;
}
export const drawPinOnCanvas = (
{ color }: Pick<Args, 'color'>,
ctx: CanvasRenderingContext2D,
): void => {
const path = new Path2D(PIN_PATH2D);
ctx.fillStyle = color;
ctx.fill(path);
};
export const getTextWidth = (value: number): number => {
switch (true) {
case value === SMALL_TEXT_VALUE:
return PIN_SIZE.width / QUARTER;
case value < MEDIUM_TEXT_VALUE:
return PIN_SIZE.width / THIRD;
case value < BIG_TEXT_VALUE:
return PIN_SIZE.width / HALF;
default:
return PIN_SIZE.width / ONE_AND_HALF;
}
};
export const getTextPosition = (textWidth: number, textHeight: number): Point => ({
x: (PIN_SIZE.width - textWidth) / HALF,
y: (PIN_SIZE.height - textHeight) / TWO_AND_HALF,
});
export const drawNumberOnCanvas = (
{ value }: Pick<Args, 'value'>,
ctx: CanvasRenderingContext2D,
): void => {
const text = `${value}`;
const textMetrics = ctx.measureText(text);
const textWidth = getTextWidth(value);
const fontSize = getFontSizeToFit(ctx, text, DEFAULT_FONT_FAMILY, textWidth);
const textHeight = textMetrics.fontBoundingBoxAscent + textMetrics.fontBoundingBoxDescent;
const { x, y } = getTextPosition(textWidth, textHeight);
ctx.fillStyle = 'white';
ctx.textBaseline = 'top';
ctx.font = `${fontSize}px ${DEFAULT_FONT_FAMILY}`;
ctx.fillText(text, x, y);
};
export const getCanvasIcon = (args: Args): HTMLCanvasElement => {
const canvas = createCanvas(PIN_SIZE);
const ctx = canvas.getContext('2d');
if (!ctx) {
return canvas;
}
drawPinOnCanvas(args, ctx);
drawNumberOnCanvas(args, ctx);
return canvas;
};
......@@ -5,7 +5,9 @@ import { initialMapStateFixture } from '@/redux/map/map.fixtures';
import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock';
import { renderHook, waitFor } from '@testing-library/react';
import { Map } from 'ol';
import BaseLayer from 'ol/layer/Base';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import React from 'react';
import { useOlMapLayers } from './useOlMapLayers';
import { useOlMap } from '../useOlMap';
......@@ -50,7 +52,7 @@ describe('useOlMapLayers - util', () => {
await waitFor(() => expect(setLayersSpy).toBeCalledTimes(CALLED_ONCE));
});
it('should return valid View instance', async () => {
const getRenderedHookResults = (): BaseLayer[] => {
const { Wrapper } = getReduxWrapperWithStore({
map: {
data: {
......@@ -93,7 +95,20 @@ describe('useOlMapLayers - util', () => {
},
);
expect(result.current[0]).toBeInstanceOf(TileLayer);
expect(result.current[0].getSourceState()).toBe('ready');
return result.current;
};
it('should return valid TileLayer instance', () => {
const result = getRenderedHookResults();
expect(result[0]).toBeInstanceOf(TileLayer);
expect(result[0].getSourceState()).toBe('ready');
});
it('should return valid VectorLayer instance', () => {
const result = getRenderedHookResults();
expect(result[1]).toBeInstanceOf(VectorLayer);
expect(result[1].getSourceState()).toBe('ready');
});
});
/* eslint-disable no-magic-numbers */
import { OPTIONS } from '@/constants/map';
import { currentBackgroundImagePathSelector } from '@/redux/backgrounds/background.selectors';
import { mapDataSizeSelector } from '@/redux/map/map.selectors';
import { projectDataSelector } from '@/redux/project/project.selectors';
import TileLayer from 'ol/layer/Tile';
import { XYZ } from 'ol/source';
import { useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useEffect } from 'react';
import { MapConfig, MapInstance } from '../../MapViewer.types';
import { getMapTileUrl } from './getMapTileUrl';
import { useOlMapPinsLayer } from './useOlMapPinsLayer';
import { useOlMapTileLayer } from './useOlMapTileLayer';
interface UseOlMapLayersInput {
mapInstance: MapInstance;
}
export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => {
const mapSize = useSelector(mapDataSizeSelector);
const currentBackgroundImagePath = useSelector(currentBackgroundImagePathSelector);
const project = useSelector(projectDataSelector);
const sourceUrl = useMemo(
() => getMapTileUrl({ projectDirectory: project?.directory, currentBackgroundImagePath }),
[project?.directory, currentBackgroundImagePath],
);
const source = useMemo(
() =>
new XYZ({
url: sourceUrl,
maxZoom: mapSize.maxZoom,
minZoom: mapSize.minZoom,
tileSize: mapSize.tileSize,
wrapX: OPTIONS.wrapXInTileLayer,
}),
[sourceUrl, mapSize.maxZoom, mapSize.minZoom, mapSize.tileSize],
);
const tileLayer = useMemo(
(): TileLayer<XYZ> =>
new TileLayer({
visible: true,
source,
}),
[source],
);
const tileLayer = useOlMapTileLayer();
const pinsLayer = useOlMapPinsLayer();
useEffect(() => {
if (!mapInstance) {
return;
}
mapInstance.setLayers([tileLayer]);
}, [tileLayer, mapInstance]);
mapInstance.setLayers([tileLayer, pinsLayer]);
}, [tileLayer, pinsLayer, mapInstance]);
return [tileLayer];
return [tileLayer, pinsLayer];
};
/* eslint-disable no-magic-numbers */
import { PIN_SIZE } from '@/constants/canvas';
import { allBioEntitesSelectorOfCurrentMap } from '@/redux/bioEntity/bioEntity.selectors';
import { BioEntity } from '@/types/models';
import { UsePointToProjectionResult, usePointToProjection } from '@/utils/map/usePointToProjection';
import { Feature } from 'ol';
import { Point as OlPoint } from 'ol/geom';
import BaseLayer from 'ol/layer/Base';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import Icon from 'ol/style/Icon';
import Style from 'ol/style/Style';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { getCanvasIcon } from './getCanvasIcon';
const getPinFeature = (
{ x, y, width, height, name }: BioEntity,
pointToProjection: UsePointToProjectionResult,
): Feature => {
const point = {
x: x + width / 2,
y: y + height / 2,
};
return new Feature({
geometry: new OlPoint(pointToProjection(point)),
name,
});
};
const getPinStyle = ({ value, color }: { value: number; color: string }): Style =>
new Style({
image: new Icon({
displacement: [0, PIN_SIZE.height],
anchorXUnits: 'fraction',
anchorYUnits: 'pixels',
img: getCanvasIcon({
color,
value,
}),
}),
});
export const useOlMapPinsLayer = (): BaseLayer => {
const pointToProjection = usePointToProjection();
const bioEntites = useSelector(allBioEntitesSelectorOfCurrentMap);
const bioEntityFeatures = useMemo(
() =>
bioEntites.map(({ bioEntity }, index) => {
const feature = getPinFeature(bioEntity, pointToProjection);
const style = getPinStyle({
color: '#106AD7',
value: index + 1,
});
feature.setStyle(style);
return feature;
}),
[bioEntites, pointToProjection],
);
const vectorSource = useMemo(() => {
return new VectorSource({
features: [...bioEntityFeatures],
});
}, [bioEntityFeatures]);
const pinsLayer = useMemo(
() =>
new VectorLayer({
source: vectorSource,
}),
[vectorSource],
);
return pinsLayer;
};
/* eslint-disable no-magic-numbers */
import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants';
import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
import { renderHook } from '@testing-library/react';
import BaseLayer from 'ol/layer/Base';
import TileLayer from 'ol/layer/Tile';
import React from 'react';
import { useOlMapTileLayer } from './useOlMapTileLayer';
const useRefValue = {
current: null,
};
Object.defineProperty(useRefValue, 'current', {
get: jest.fn(() => ({
innerHTML: '',
appendChild: jest.fn(),
addEventListener: jest.fn(),
getRootNode: jest.fn(),
})),
set: jest.fn(() => ({
innerHTML: '',
appendChild: jest.fn(),
addEventListener: jest.fn(),
getRootNode: jest.fn(),
})),
});
jest.spyOn(React, 'useRef').mockReturnValue(useRefValue);
describe('useOlMapTileLayer - util', () => {
const getRenderedHookResults = (): BaseLayer => {
const { Wrapper } = getReduxWrapperWithStore({
map: {
data: {
...MAP_DATA_INITIAL_STATE,
size: {
width: 256,
height: 256,
tileSize: 256,
minZoom: 1,
maxZoom: 1,
},
position: {
initial: {
x: 256,
y: 256,
},
last: {
x: 256,
y: 256,
},
},
},
loading: 'idle',
error: {
name: '',
message: '',
},
openedMaps: OPENED_MAPS_INITIAL_STATE,
},
});
const { result } = renderHook(() => useOlMapTileLayer(), {
wrapper: Wrapper,
});
return result.current;
};
it('should return valid TileLayer instance', () => {
const result = getRenderedHookResults();
expect(result).toBeInstanceOf(TileLayer);
expect(result.getSourceState()).toBe('ready');
});
});
/* eslint-disable no-magic-numbers */
import { OPTIONS } from '@/constants/map';
import { currentBackgroundImagePathSelector } from '@/redux/backgrounds/background.selectors';
import { mapDataSizeSelector } from '@/redux/map/map.selectors';
import { projectDataSelector } from '@/redux/project/project.selectors';
import BaseLayer from 'ol/layer/Base';
import TileLayer from 'ol/layer/Tile';
import { XYZ } from 'ol/source';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { getMapTileUrl } from './getMapTileUrl';
// useOlMapTileLayer returns visual tile layer of the map
// it makes it possible to view the map, scroll, zoom etc.
export const useOlMapTileLayer = (): BaseLayer => {
const mapSize = useSelector(mapDataSizeSelector);
const currentBackgroundImagePath = useSelector(currentBackgroundImagePathSelector);
const project = useSelector(projectDataSelector);
const sourceUrl = useMemo(
() => getMapTileUrl({ projectDirectory: project?.directory, currentBackgroundImagePath }),
[project?.directory, currentBackgroundImagePath],
);
const source = useMemo(
() =>
new XYZ({
url: sourceUrl,
maxZoom: mapSize.maxZoom,
minZoom: mapSize.minZoom,
tileSize: mapSize.tileSize,
wrapX: OPTIONS.wrapXInTileLayer,
}),
[sourceUrl, mapSize.maxZoom, mapSize.minZoom, mapSize.tileSize],
);
const tileLayer = useMemo(
(): TileLayer<XYZ> =>
new TileLayer({
visible: true,
source,
}),
[source],
);
return tileLayer;
};
/* eslint-disable no-magic-numbers */
import { setMapPosition } from '@/redux/map/map.slice';
import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
import { act, renderHook, waitFor } from '@testing-library/react';
import { initialMapStateFixture } from '@/redux/map/map.fixtures';
import {
BACKGROUNDS_MOCK,
BACKGROUND_INITIAL_STATE_MOCK,
} from '@/redux/backgrounds/background.mock';
import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants';
import { initialMapStateFixture } from '@/redux/map/map.fixtures';
import { setMapPosition } from '@/redux/map/map.slice';
import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
import { act, renderHook, waitFor } from '@testing-library/react';
import { View } from 'ol';
import Map from 'ol/Map';
import React from 'react';
......
......@@ -37,10 +37,11 @@ export const handleReactionResults =
return;
}
const { products, reactants } = payload[FIRST];
const { products, reactants, modifiers } = payload[FIRST];
const productsIds = products.map(p => p.aliasId);
const reactantsIds = reactants.map(r => r.aliasId);
const bioEntitiesIds = [...productsIds, ...reactantsIds].map(identifier => String(identifier));
const modifiersIds = modifiers.map(m => m.aliasId);
const bioEntitiesIds = [...productsIds, ...reactantsIds, ...modifiersIds].map(identifier => String(identifier));
dispatch(setBioEntityContent([]));
await dispatch(
......
import { FunctionalArea } from '@/components/FunctionalArea';
import { Map } from '@/components/Map';
import { manrope } from '@/constants/font';
import { useReduxBusQueryManager } from '@/utils/query-manager/useReduxBusQueryManager';
import { Manrope } from '@next/font/google';
import { twMerge } from 'tailwind-merge';
import { useInitializeStore } from '../../utils/initialize/useInitializeStore';
const manrope = Manrope({
variable: '--font-manrope',
display: 'swap',
weight: ['400', '700'],
subsets: ['latin'],
});
export const MinervaSPA = (): JSX.Element => {
useInitializeStore();
useReduxBusQueryManager();
......
export const PIN_PATH2D =
'M12.3077 0C6.25641 0 0 4.61538 0 12.3077C0 19.5897 11.0769 30.9744 11.5897 31.4872C11.7949 31.6923 12 31.7949 12.3077 31.7949C12.6154 31.7949 12.8205 31.6923 13.0256 31.4872C13.5385 30.9744 24.6154 19.6923 24.6154 12.3077C24.6154 4.61538 18.359 0 12.3077 0Z';
export const PIN_SIZE = {
width: 25,
height: 32,
};
export const ONE_AND_HALF = 1.5;
export const HALF = 2;
export const TWO_AND_HALF = 2.5;
export const THIRD = 3;
export const QUARTER = 4;
import { Manrope } from '@next/font/google';
export const manrope = Manrope({
variable: '--font-manrope',
display: 'swap',
weight: ['400', '700'],
subsets: ['latin'],
});
export const DEFAULT_FONT_FAMILY = manrope.style.fontFamily;
......@@ -11,7 +11,7 @@ export const reactionSchema = z.object({
kineticLaw: z.null(),
lines: z.array(reactionLineSchema),
modelId: z.number(),
modifiers: z.array(z.unknown()),
modifiers: z.array(productsSchema),
name: z.string(),
notes: z.string(),
products: z.array(productsSchema),
......
import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common';
import { rootSelector } from '@/redux/root/root.selectors';
import { BioEntityContent } from '@/types/models';
import { createSelector } from '@reduxjs/toolkit';
import { currentModelIdSelector } from '../models/models.selectors';
export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity);
......@@ -9,6 +11,13 @@ export const loadingBioEntityStatusSelector = createSelector(
state => state.loading,
);
export const allBioEntitesSelectorOfCurrentMap = createSelector(
bioEntitySelector,
currentModelIdSelector,
(state, currentModelId): BioEntityContent[] =>
(state?.data || []).filter(({ bioEntity }) => bioEntity.model === currentModelId),
);
export const numberOfBioEntitiesSelector = createSelector(bioEntitySelector, state =>
state.data ? state.data.length : SIZE_OF_EMPTY_ARRAY,
);
......
/* eslint-disable no-magic-numbers */
import { createCanvas } from './getCanvas';
describe('getCanvas', () => {
it('should return HTMLCanvasElement with valid size on positive params', () => {
const result = createCanvas({
width: 800,
height: 600,
});
expect(result).toBeInstanceOf(HTMLCanvasElement);
expect(result.width).toEqual(800);
expect(result.height).toEqual(600);
});
});
export const createCanvas = ({
width,
height,
}: {
width: number;
height: number;
}): HTMLCanvasElement => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
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