diff --git a/package-lock.json b/package-lock.json index d61b7297f78cbf13c0656db0554f629e1362436f..f5d700154ac7b2e64101b071c444d44034bbfeba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6e3f2e39d607187aa51823be6783b7581b30c9ed..6d2d85292d9f9ee9d0bb0f7bcd975fbddfcda2e2 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,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", diff --git a/setupTests.ts b/setupTests.ts index 11a7da9a6d761c10f3f1336c1c4c2c4a13c3e6fb..db87ae92bfff490a04acbd652192bbc003c28b03 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom'; +import 'jest-canvas-mock'; // used by openlayers module global.ResizeObserver = jest.fn().mockImplementation(() => ({ diff --git a/src/components/Map/MapViewer/utils/config/getCanvasIcon.test.ts b/src/components/Map/MapViewer/utils/config/getCanvasIcon.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa5b864020822722db5351f870bb64595382ed2c --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/getCanvasIcon.test.ts @@ -0,0 +1,100 @@ +/* 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); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/getCanvasIcon.ts b/src/components/Map/MapViewer/utils/config/getCanvasIcon.ts new file mode 100644 index 0000000000000000000000000000000000000000..c377b0efa124b12dc7ad8633e11d3f80cb5d0df0 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/getCanvasIcon.ts @@ -0,0 +1,73 @@ +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; +}; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts index ac543476c5fb61ca762409d68eb586bf6f564a05..c240ae88a5017c0108be17e74e272dcf84abe855 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts @@ -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'); }); }); diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index 67d71d50060f40018ec5d515b0608a8b98bb4f92..c092a1465003e3c94414fd18582a6012dd74993a 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -1,57 +1,24 @@ /* 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]; }; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts b/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..3244cf3a75e75a2a03fb23fc8dedb7eb43677bbf --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts @@ -0,0 +1,79 @@ +/* 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; +}; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c5321cbbb294676336ad9404a7fb7a4251f1fd5 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.test.ts @@ -0,0 +1,77 @@ +/* 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'); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..b50d5c150dd2291feab71d5c5a5e0477f05e95aa --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts @@ -0,0 +1,47 @@ +/* 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; +}; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts index 21d779d60483c0faff83938e1e6c96cd9133a615..2832e68da8c053073a8d9bf713a99251c4e42a6a 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts @@ -1,13 +1,13 @@ /* 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'; diff --git a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts index acf6e51664ffb871b4aab35e36ba3199bbfe9a29..4b39092bc28a0846ae4e4e2c7ca7eee3bc50233b 100644 --- a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts +++ b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts @@ -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( diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 4de3ca1fb95c0e3bbf4b82dbc85e7afffb32d033..3376b1ca88b4dbbd938d828d5e69b95675af1aad 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -1,17 +1,10 @@ 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(); diff --git a/src/constants/canvas.ts b/src/constants/canvas.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9741f3001cb3defe3931b799a208860b4110879 --- /dev/null +++ b/src/constants/canvas.ts @@ -0,0 +1,7 @@ +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, +}; diff --git a/src/constants/dividers.ts b/src/constants/dividers.ts new file mode 100644 index 0000000000000000000000000000000000000000..402936ad0c276f9ff4b4ef36cc78ee3467752a59 --- /dev/null +++ b/src/constants/dividers.ts @@ -0,0 +1,5 @@ +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; diff --git a/src/constants/font.ts b/src/constants/font.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8ea3d4d6cb31c59c237ee51062294131e3ae497 --- /dev/null +++ b/src/constants/font.ts @@ -0,0 +1,10 @@ +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; diff --git a/src/models/reaction.ts b/src/models/reaction.ts index 7cc4e1add7f33494e45ba990b031c4a03a512642..51c7c51b014b35f76668730099639a929fb251cc 100644 --- a/src/models/reaction.ts +++ b/src/models/reaction.ts @@ -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), diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts index 43c3002e30980c9f6c09076cb632b6d54fa74bb3..dac3b8c71cb696d8c9a2bd6289f35aa7df5dfccd 100644 --- a/src/redux/bioEntity/bioEntity.selectors.ts +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -1,6 +1,8 @@ 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, ); diff --git a/src/utils/canvas/getCanvas.test.ts b/src/utils/canvas/getCanvas.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0f6f1adf1933477f52a1fcc6c1cc29a83d81e99 --- /dev/null +++ b/src/utils/canvas/getCanvas.test.ts @@ -0,0 +1,15 @@ +/* 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); + }); +}); diff --git a/src/utils/canvas/getCanvas.ts b/src/utils/canvas/getCanvas.ts new file mode 100644 index 0000000000000000000000000000000000000000..d380a465fefe0d70028ff2a7954df241885fe817 --- /dev/null +++ b/src/utils/canvas/getCanvas.ts @@ -0,0 +1,12 @@ +export const createCanvas = ({ + width, + height, +}: { + width: number; + height: number; +}): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; diff --git a/src/utils/canvas/getFontSizeToFit.test.ts b/src/utils/canvas/getFontSizeToFit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce53c089ce648a8de13c4b3f885c250731d79161 --- /dev/null +++ b/src/utils/canvas/getFontSizeToFit.test.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-magic-numbers */ +import { createCanvas } from './getCanvas'; +import { getFontSizeToFit } from './getFontSizeToFit'; + +const getContext = (): CanvasRenderingContext2D => { + const canvas = createCanvas({ width: 100, height: 100 }); + return canvas.getContext('2d') as CanvasRenderingContext2D; +}; + +describe('getFontSizeToFit', () => { + const cases: [string, string, number, number][] = [ + ['Hello', 'Helvetica', 50, 10], + ['123', 'Arial', 48, 16], + ['1', '', 48, 48], + ['Text', '', 0, 0], + ['', '', 0, 0], + ]; + it.each(cases)( + 'on text=%s, fontFace=%s, maxWidth=%s it should return value %s', + (text, fontFace, maxWidth, result) => { + const ctx = getContext(); + expect(getFontSizeToFit(ctx, text, fontFace, maxWidth)).toBeCloseTo(result); + }, + ); +}); diff --git a/src/utils/canvas/getFontSizeToFit.ts b/src/utils/canvas/getFontSizeToFit.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfad99cfec5d11e910ee7023a9abc57eaf278aaf --- /dev/null +++ b/src/utils/canvas/getFontSizeToFit.ts @@ -0,0 +1,11 @@ +const DEFAULT_VALID_SIZE = 0; + +export const getFontSizeToFit = ( + ctx: CanvasRenderingContext2D, + text: string, + fontFace: string, + maxWidth: number, +): number => { + ctx.font = `1px ${fontFace}`; + return maxWidth / ctx.measureText(text).width || DEFAULT_VALID_SIZE; +}; diff --git a/src/utils/map/usePointToProjection.ts b/src/utils/map/usePointToProjection.ts index d0b0066b98d5f73abc92c4cca76dadf86e439ead..b6309fe612213490137220700c650021df38bfcb 100644 --- a/src/utils/map/usePointToProjection.ts +++ b/src/utils/map/usePointToProjection.ts @@ -7,7 +7,7 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; import { pointToLngLat } from './pointToLatLng'; -type UsePointToProjectionResult = (point: Point) => Coordinate; +export type UsePointToProjectionResult = (point: Point) => Coordinate; type UsePointToProjection = () => UsePointToProjectionResult; diff --git a/yarn.lock b/yarn.lock index ef5cdfbf8c29f725a8559cf632cfd1d62d180e80..3ba2ee3e331785c9cd9c4f37c51d09a81789e163 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2149,7 +2149,7 @@ dependencies: "color-name" "~1.1.4" -"color-name@~1.1.4": +"color-name@^1.1.4", "color-name@~1.1.4": "integrity" "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "resolved" "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" "version" "1.1.4" @@ -2325,6 +2325,11 @@ "resolved" "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz" "version" "3.0.0" +"cssfontparser@^1.2.1": + "integrity" "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==" + "resolved" "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz" + "version" "1.2.1" + "cssom@^0.5.0": "integrity" "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" "resolved" "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz" @@ -4427,6 +4432,14 @@ "reflect.getprototypeof" "^1.0.4" "set-function-name" "^2.0.1" +"jest-canvas-mock@^2.5.2": + "integrity" "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==" + "resolved" "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz" + "version" "2.5.2" + dependencies: + "cssfontparser" "^1.2.1" + "moo-color" "^1.0.2" + "jest-changed-files@^29.7.0": "integrity" "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==" "resolved" "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz" @@ -5353,6 +5366,13 @@ "resolved" "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" "version" "1.0.4" +"moo-color@^1.0.2": + "integrity" "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==" + "resolved" "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz" + "version" "1.0.3" + dependencies: + "color-name" "^1.1.4" + "ms@^2.1.1", "ms@2.1.2": "integrity" "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "resolved" "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"