From b02ce889f32841c4fa1812e9913db1e0109a3ed3 Mon Sep 17 00:00:00 2001 From: PahaN47 Date: Tue, 28 Oct 2025 18:55:13 +0300 Subject: [PATCH] feat(Map): add margins --- memory-bank/usage/map.md | 2 + src/components/Map/README.md | 2 + src/components/Map/YMap/YMap.ts | 73 +++++++--- src/components/Map/YMap/YandexMap.tsx | 5 +- src/components/Map/YMap/utils.ts | 130 ++++++++++++++++++ src/components/Map/__stories__/Map.mdx | 2 + .../Map/__stories__/Map.stories.scss | 10 -- .../Map/__stories__/Map.stories.tsx | 15 +- src/models/constructor-items/blocks.ts | 2 +- src/models/constructor-items/common.ts | 8 +- src/schema/validators/common.ts | 3 - 11 files changed, 210 insertions(+), 42 deletions(-) create mode 100644 src/components/Map/YMap/utils.ts delete mode 100644 src/components/Map/__stories__/Map.stories.scss diff --git a/memory-bank/usage/map.md b/memory-bank/usage/map.md index 7dde6f2a2..222854615 100644 --- a/memory-bank/usage/map.md +++ b/memory-bank/usage/map.md @@ -72,8 +72,10 @@ graph TD - `id`: Unique identifier for the map instance (required) - `zoom`: Optional zoom level (inherited from MapBaseProps) - `className`: Optional CSS class name (inherited from MapBaseProps) + - `forceAspectRatio`: Optional boolean to force aspect ratio (16:9 for Desktop, 4:3 for Mobile), `true` by default (inherited from MapBaseProps) - `disableControls`: Optional boolean to hide map controls (Yandex Maps only), `false` by default - `disableBalloons`: Optional boolean to disable info balloons (Yandex Maps only), `false` by default + - `areaMargin:` - Optional offset (in pixels) for the marked area of the map relative to the map's container (`30` by default) #### MapBaseProps (Common Props) diff --git a/src/components/Map/README.md b/src/components/Map/README.md index 66d9a751d..a6639d045 100644 --- a/src/components/Map/README.md +++ b/src/components/Map/README.md @@ -14,6 +14,8 @@ Map `disableBalloons?: boolean` - If `true`, disables info ballon opening when clicking on a marker. Only for `Yandex maps`. `false` by default +`areaMargin?: number | [number, number] | [number, number, number]` - Offset (in pixels) for the marked area of the map relative to the map's container. Only for `Yandex maps`. `30` by default + `markers?: object[]` - Description for placemarkers. You need to use it for `Yandex maps`. Specify the parameters given below. - `address?: string` — Place name, address diff --git a/src/components/Map/YMap/YMap.ts b/src/components/Map/YMap/YMap.ts index 7df3e0abe..a6439f43e 100644 --- a/src/components/Map/YMap/YMap.ts +++ b/src/components/Map/YMap/YMap.ts @@ -1,6 +1,8 @@ import {YMapMarkerLabelPrivate, YMapMarkerPrivate, YMapProps} from '../../../models'; import {Coordinate} from '../../../models/constructor-items/common'; +import {ParsedMargin, calculateMapParamsWithMarginAndZoom, parseMargin} from './utils'; + enum GeoObjectTypes { Properties = 'properties', Options = 'options', @@ -26,7 +28,7 @@ const geoObjectPropsAndOptions: Record; +type PlacemarksProps = Pick; export class YMap { private ymap: Ymaps.Map; @@ -97,45 +99,76 @@ export class YMap { }); } + // eslint-disable-next-line complexity private recalcZoomAndCenter(props: PlacemarksProps) { const coordsLength = this.coords.length; - const {zoom = 0} = props; + const {zoom = 0, areaMargin} = props; if (!coordsLength) { return; } - let leftBottom = [Infinity, Infinity], - rightTop = [-Infinity, -Infinity]; + const utils = window.ymaps.util.bounds; - this.coords.forEach((point) => { - leftBottom = [Math.min(leftBottom[0], point[0]), Math.min(leftBottom[1], point[1])]; - rightTop = [Math.max(rightTop[0], point[0]), Math.max(rightTop[1], point[1])]; - }); + const [leftTop, rightBottom] = utils.fromPoints(this.coords); let newMapParams = { zoom, center: [], }; - if (zoom) { - // compute only the center - newMapParams.center = window.ymaps.util.bounds.getCenter([leftBottom, rightTop]); - } else { - newMapParams = window.ymaps.util.bounds.getCenterAndZoom( - [leftBottom, rightTop], - [this.mapRef?.clientWidth, this.mapRef?.clientHeight], - undefined, - {margin: DEFAULT_MAP_CONTROL_BUTTON_HEIGHT}, - ); + const parsedAreaMargin = areaMargin + ? parseMargin(areaMargin) + : ([0, 0, 0, 0] as ParsedMargin); + + const hasZoom = Boolean(zoom); + const hasAreaMargin = parsedAreaMargin.some(Boolean); + const containerSize = [ + this.mapRef?.clientWidth ?? 0, + this.mapRef?.clientHeight ?? 0, + ] as Coordinate; + + switch (true) { + case hasAreaMargin && hasZoom: + // calculate center and zoom in accordace with current zoom and margin + newMapParams = calculateMapParamsWithMarginAndZoom( + [leftTop, rightBottom], + zoom, + parsedAreaMargin, + containerSize, + ); + break; + case hasAreaMargin: + // calculate center and zoom with custom margin + newMapParams = utils.getCenterAndZoom( + [leftTop, rightBottom], + containerSize, + undefined, + {margin: areaMargin, preciseZoom: true}, + ); + break; + case hasZoom: + // calculate only center + newMapParams.center = utils.getCenter([leftTop, rightBottom]); + break; + default: + // calculate center and zoom with default margin + newMapParams = utils.getCenterAndZoom( + [leftTop, rightBottom], + containerSize, + undefined, + {margin: DEFAULT_MAP_CONTROL_BUTTON_HEIGHT}, + ); } this.ymap.setCenter(newMapParams.center); // Use default zoom for one placemark - if (coordsLength > 1 && !zoom) { - this.ymap.setZoom(newMapParams.zoom); + if (coordsLength <= 1 && !hasAreaMargin && hasZoom) { + return; } + + this.ymap.setZoom(newMapParams.zoom); } private clearOldPlacemarks() { diff --git a/src/components/Map/YMap/YandexMap.tsx b/src/components/Map/YMap/YandexMap.tsx index 862abe318..4138227d9 100644 --- a/src/components/Map/YMap/YandexMap.tsx +++ b/src/components/Map/YMap/YandexMap.tsx @@ -35,6 +35,7 @@ const YandexMap = (props: YMapProps) => { id, disableControls = false, disableBalloons = false, + areaMargin, className, forceAspectRatio = true, } = props; @@ -127,14 +128,14 @@ const YandexMap = (props: YMapProps) => { })) : markers; - await ymap.showPlacemarks({markers: privateMarkers, zoom}); + await ymap.showPlacemarks({markers: privateMarkers, zoom, areaMargin}); setReady(true); }; showPlacemarks(); } - }, [ymap, markers, zoom, disableBalloons]); + }, [ymap, markers, zoom, disableBalloons, areaMargin]); if (!markers) return null; diff --git a/src/components/Map/YMap/utils.ts b/src/components/Map/YMap/utils.ts new file mode 100644 index 000000000..ef1905ac9 --- /dev/null +++ b/src/components/Map/YMap/utils.ts @@ -0,0 +1,130 @@ +import {Coordinate, YMapMargin} from '../../../models'; + +export type ParsedMargin = [top: number, right: number, bottom: number, left: number]; + +export const parseMargin = (margin: YMapMargin): ParsedMargin => { + if (!Array.isArray(margin)) { + return [margin, margin, margin, margin]; + } + + if (margin.length === 2) { + return [margin[0], margin[1], margin[0], margin[1]]; + } + + return margin; +}; + +export const calcPixelBounds = ( + [leftTop, rightBottom]: [Coordinate, Coordinate], + zoom: number, + containerSize: Coordinate, +) => { + const utils = window.ymaps.util.bounds; + + let [[leftPx, topPx], [rightPx, bottomPx]] = utils.toGlobalPixelBounds( + [leftTop, rightBottom], + zoom, + ) as [Coordinate, Coordinate]; + + // fall back to container size in case there is only one marker and area is 0 + if (rightPx - leftPx <= 0) { + const halfX = containerSize[0] / 2; + leftPx -= halfX; + rightPx += halfX; + } + + if (bottomPx - topPx <= 0) { + const halfY = containerSize[1] / 2; + topPx -= halfY; + bottomPx += halfY; + } + + return [ + [leftPx, topPx], + [rightPx, bottomPx], + ]; +}; + +const calcNewZoom = (l: number, zoom: number, marginSum: number) => { + return Math.log2((Math.pow(2, zoom) * (l - marginSum)) / l); +}; + +export const calculateMapParamsWithMarginAndZoom = ( + [leftTop, rightBottom]: [Coordinate, Coordinate], + zoom: number, + areaMargin: ParsedMargin, + containerSize: Coordinate, +) => { + const utils = window.ymaps.util.bounds; + + // calculate pixel bounds with current zoom + let [[leftPx, topPx], [rightPx, bottomPx]] = calcPixelBounds( + [leftTop, rightBottom], + zoom, + containerSize, + ); + + const [topMargin, rightMargin, bottomMargin, leftMargin] = areaMargin; + + let zoomV: number; + let zoomH: number; + + // calculate new zoom value after margins are applied + if (leftMargin && rightMargin) { + zoomH = calcNewZoom(rightPx - leftPx, zoom, leftMargin + rightMargin); + } else { + zoomH = zoom; + } + + if (topMargin && bottomMargin) { + zoomV = calcNewZoom(bottomPx - topPx, zoom, topMargin + bottomMargin); + } else { + zoomV = zoom; + } + + const newZoom = Math.min(zoomV, zoomH); + + // calculate pixel bounds with new zoom + [[leftPx, topPx], [rightPx, bottomPx]] = calcPixelBounds( + [leftTop, rightBottom], + newZoom, + containerSize, + ); + + // calculate new bounds (scale if both size are present, otherwise shift the map) + if (leftMargin && rightMargin) { + leftPx -= leftMargin; + rightPx += rightMargin; + } else if (leftMargin) { + leftPx -= leftMargin; + rightPx -= leftMargin; + } else if (rightMargin) { + leftPx += rightMargin; + rightPx += rightMargin; + } + + if (topMargin && bottomMargin) { + topPx -= topMargin; + bottomPx += bottomMargin; + } else if (topMargin) { + topPx -= topMargin; + bottomPx -= topMargin; + } else if (bottomMargin) { + topPx += bottomMargin; + bottomPx += bottomMargin; + } + + // transform new bounds into coordinates + const [newLeftTop, newRightBottom] = utils.fromGlobalPixelBounds( + [ + [leftPx, topPx], + [rightPx, bottomPx], + ], + newZoom, + ); + + return { + center: utils.getCenter([newLeftTop, newRightBottom]), + zoom: newZoom, + }; +}; diff --git a/src/components/Map/__stories__/Map.mdx b/src/components/Map/__stories__/Map.mdx index b9c480cb8..fdc14d967 100644 --- a/src/components/Map/__stories__/Map.mdx +++ b/src/components/Map/__stories__/Map.mdx @@ -34,6 +34,8 @@ For a detailed usage guide of the Map component, see [Map Usage](https://github. `disableBalloons?: boolean` - If `true`, disables info ballon opening when clicking on a marker (`false` by default) +`areaMargin?: number | [number, number] | [number, number, number]` - Offset (in pixels) for the marked area of the map relative to the map's container (`30` by default) + #### YMapMarker Interface `address?: string` — Optional string address for the marker diff --git a/src/components/Map/__stories__/Map.stories.scss b/src/components/Map/__stories__/Map.stories.scss deleted file mode 100644 index ddd94ce28..000000000 --- a/src/components/Map/__stories__/Map.stories.scss +++ /dev/null @@ -1,10 +0,0 @@ -.aspect-ratio-story { - display: flex; - flex-direction: column; - gap: 16px; - padding: 0 24px; - - &-map { - width: 100%; - } -} diff --git a/src/components/Map/__stories__/Map.stories.tsx b/src/components/Map/__stories__/Map.stories.tsx index dff9200fc..03c2ccf9a 100644 --- a/src/components/Map/__stories__/Map.stories.tsx +++ b/src/components/Map/__stories__/Map.stories.tsx @@ -10,8 +10,6 @@ import {ApiKeyInput} from './ApiKeyInput'; import data from './data.json'; -import './Map.stories.scss'; - const maxMapWidth = 500; export default { @@ -47,22 +45,29 @@ export const YMap = YMapTemplate.bind({}); export const YMapHiddenControls = YMapTemplate.bind({}); export const YMapHiddenBalloons = YMapTemplate.bind({}); export const YMapCustomMarkers = YMapTemplate.bind({}); +export const YMapAreaOffset = YMapTemplate.bind({}); YMapHiddenControls.storyName = 'Y Map (Hidden Controls)'; YMapHiddenBalloons.storyName = 'Y Map (Hidden Balloons)'; YMapCustomMarkers.storyName = 'Y Map (Custom Markers)'; +YMapAreaOffset.storyName = 'Y Map (Area Margin)'; GoogleMap.args = data.gmap; -YMap.args = data.ymap; +YMap.args = data.ymap as MapProps; YMapHiddenControls.args = { ...data.ymap, disableControls: true, -}; +} as MapProps; YMapHiddenBalloons.args = { ...data.ymap, disableBalloons: true, -}; +} as MapProps; YMapCustomMarkers.args = data.ymapCustomMarkers as MapProps; + +YMapAreaOffset.args = { + ...data.ymap, + areaMargin: [0, 0, 0, 200], +} as MapProps; diff --git a/src/models/constructor-items/blocks.ts b/src/models/constructor-items/blocks.ts index f7a2ce411..ea752e5ea 100644 --- a/src/models/constructor-items/blocks.ts +++ b/src/models/constructor-items/blocks.ts @@ -292,7 +292,7 @@ export interface MediaBlockProps extends MediaBaseBlockProps, WithBorder { } export interface MapBlockProps extends MediaBaseBlockProps, WithBorder { - map: Omit; + map: Omit; } export interface InfoBlockProps { diff --git a/src/models/constructor-items/common.ts b/src/models/constructor-items/common.ts index 09bcadd91..ff001588a 100644 --- a/src/models/constructor-items/common.ts +++ b/src/models/constructor-items/common.ts @@ -323,7 +323,7 @@ export interface BackgroundMediaProps extends MediaProps, Animatable, QAProps { mediaClassName?: string; } -export type Coordinate = number[]; +export type Coordinate = [number, number]; export interface MapBaseProps { zoom?: number; @@ -335,10 +335,16 @@ export interface GMapProps extends MapBaseProps { address: string; } +export type YMapMargin = + | number + | [vertical: number, horizontal: number] + | [top: number, right: number, bottom: number, left: number]; + export interface YMapProps extends MapBaseProps { markers: YMapMarker[]; disableControls?: boolean; disableBalloons?: boolean; + areaMargin?: YMapMargin; id: string; } diff --git a/src/schema/validators/common.ts b/src/schema/validators/common.ts index 9a9f326f5..766aebd57 100644 --- a/src/schema/validators/common.ts +++ b/src/schema/validators/common.ts @@ -715,9 +715,6 @@ export const MapProps = { type: 'array', items: YMapMarker, }, - forceAspectRatio: { - type: 'boolean', - }, disableControls: { type: 'boolean', },