diff --git a/.storybook/public/story-assets/map-mark.svg b/.storybook/public/story-assets/map-mark.svg new file mode 100644 index 000000000..b5ebf6957 --- /dev/null +++ b/.storybook/public/story-assets/map-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/memory-bank/usage/map.md b/memory-bank/usage/map.md index 90c037c1e..7dde6f2a2 100644 --- a/memory-bank/usage/map.md +++ b/memory-bank/usage/map.md @@ -61,15 +61,27 @@ graph TD - `address`: String address to display on the map (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) #### YMapProps (Yandex Maps) - **Description**: Props for Yandex Maps implementation +- **File**: `src/components/Map/YMap/YMap.tsx` - **Properties**: - `markers`: Array of YMapMarker objects to display on the map (required) - `id`: Unique identifier for the map instance (required) - `zoom`: Optional zoom level (inherited from MapBaseProps) - `className`: Optional CSS class name (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 + +#### MapBaseProps (Common Props) + +- **Description**: Common props available for both Google Maps and Yandex Maps +- **Properties**: + - `zoom`: Optional zoom level for the map + - `className`: Optional CSS class name for styling + - `forceAspectRatio`: Optional boolean to force aspect ratio (16:9 for Desktop, 4:3 for Mobile), `true` by default ### YMapMarker Interface @@ -86,6 +98,12 @@ graph TD - `iconCaption`: Optional caption text for the marker - `iconContent`: Optional content text for the marker - `iconColor`: Optional color for the marker icon + - `iconImageHref`: Optional URL of geo object's custom icon image file + - `iconImageSize`: Optional dimensions of custom icon image [width, height] + - `iconImageOffset`: Optional custom icon image's offset relative to it's anchor point [x, y] + - `iconImageClipRect`: Optional coordinates of custom icon image's displayed rectangular area [[x1, y1], [x2, y2]] + - `iconLayout`: Optional layout for icon (e.g., 'default#image' for custom icons) + - `iconShape`: Optional icon's active area shape - `preset`: Optional preset style for the marker ### MapsContext diff --git a/src/blocks/Map/Map.tsx b/src/blocks/Map/Map.tsx index a7733ec6d..636ef119f 100644 --- a/src/blocks/Map/Map.tsx +++ b/src/blocks/Map/Map.tsx @@ -1,6 +1,6 @@ import Map from '../../components/Map/Map'; import MediaBase from '../../components/MediaBase/MediaBase'; -import {MapBlockProps} from '../../models'; +import {MapBlockProps, MapProps} from '../../models'; import {block} from '../../utils'; import {getMediaBorder} from '../../utils/borderSelector'; @@ -17,7 +17,7 @@ export const MapBlock = ({map, border, disableShadow, ...props}: MapBlockProps) return ( - + ); diff --git a/src/components/Map/GoogleMap.tsx b/src/components/Map/GoogleMap.tsx index 5d9b9cab0..6aa322431 100644 --- a/src/components/Map/GoogleMap.tsx +++ b/src/components/Map/GoogleMap.tsx @@ -30,7 +30,7 @@ function getScriptSrc(params: GoogleMapLinkParams) { } const GoogleMap = (props: GMapProps) => { - const {address, zoom, className} = props; + const {address, zoom, className, forceAspectRatio = true} = props; const {apiKey, scriptSrc} = React.useContext(MapsContext); const {lang = Lang.Ru} = React.useContext(LocaleContext); const isMobile = React.useContext(MobileContext); @@ -43,6 +43,10 @@ const GoogleMap = (props: GMapProps) => { ); React.useEffect(() => { + if (!forceAspectRatio) { + return; + } + const updateSize = debounce(() => { if (ref.current) { setHeight(Math.round(getMapHeight(ref.current.offsetWidth, isMobile))); @@ -52,10 +56,11 @@ const GoogleMap = (props: GMapProps) => { updateSize(); window.addEventListener('resize', updateSize, {passive: true}); + // eslint-disable-next-line consistent-return return () => { window.removeEventListener('resize', updateSize); }; - }, [isMobile]); + }, [forceAspectRatio, isMobile]); if (!apiKey || !address) { return null; diff --git a/src/components/Map/README.md b/src/components/Map/README.md index 90865c78a..66d9a751d 100644 --- a/src/components/Map/README.md +++ b/src/components/Map/README.md @@ -6,8 +6,14 @@ Map `address?: string;` - URL-escaped place name, address. You need to use it for `Google maps` +`forceAspectRatio?: boolean` - Determines whether map's aspect ratio is forced autmatically (16:9 for Desktop, 4:3 for Mobile), `true` by default + `id?: string` - map id. You need to use it for `Yandex maps`. As an id, you can specify a short description, for example `offices`, and the full id will be `ymap-offices` +`disableControls?: boolean` - If `true`, hides map's default controls. Only for `Yandex maps`. `false` by default + +`disableBalloons?: boolean` - If `true`, disables info ballon opening when clicking on a marker. Only for `Yandex maps`. `false` 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 @@ -17,4 +23,10 @@ Map - `iconCaption?: string` - Caption for the geo object's icon - `iconContent?: string` - Content of the geo object's icon - `iconColor?: string` - The color of the placemark. There are three ways to set the color: using a keyword, in Hex format, or RGB. A red placemark is used by default. + - `iconImageHref?: string` - URL of geo object's custom icon image file + - `iconImageSize?: [number, number]` - Dimensions of custom icon image + - `iconImageOffset?: [number, number]` - Custom icon image's offset relative to it's anchor point + - `iconImageClipRect?: [[number, number], [number, number]]` - Coordinates of custom icon image's displayed rectangular area, in pixels + - `iconLayout?: 'default#image'` - Required to use custom icons for a geo object + - `iconShape?: Record` - Icon's active area shape. Refer to documentation [e.g. Circle](https://yandex.ru/dev/jsapi-v2-1/doc/ru/v2-1/ref/reference/shape.Circle) - `preset?: string` - Key for the placemark's preset options. A `islands#dotIcon` is used by default. The list of available keys is stored in the [presetStorage](https://yandex.com/dev/maps/jsapi/doc/2.1/ref/reference/option.presetStorage.html) description diff --git a/src/components/Map/YMap/YMap.ts b/src/components/Map/YMap/YMap.ts index b5f1c0c95..7df3e0abe 100644 --- a/src/components/Map/YMap/YMap.ts +++ b/src/components/Map/YMap/YMap.ts @@ -1,4 +1,4 @@ -import {YMapMarker, YMapMarkerLabel, YMapProps} from '../../../models'; +import {YMapMarkerLabelPrivate, YMapMarkerPrivate, YMapProps} from '../../../models'; import {Coordinate} from '../../../models/constructor-items/common'; enum GeoObjectTypes { @@ -11,10 +11,18 @@ const DEFAULT_PLACEMARKS_COLOR = '#dc534b'; const DEFAULT_PLACEMARKS_PRESET = 'islands#dotIcon'; const DEFAULT_MAP_CONTROL_BUTTON_HEIGHT = 30; -const geoObjectPropsAndOptions = { +const geoObjectPropsAndOptions: Record = { + cursor: GeoObjectTypes.Options, iconCaption: GeoObjectTypes.Properties, iconContent: GeoObjectTypes.Properties, iconColor: GeoObjectTypes.Options, + iconImageHref: GeoObjectTypes.Options, + iconImageSize: GeoObjectTypes.Options, + iconImageOffset: GeoObjectTypes.Options, + iconImageClipRect: GeoObjectTypes.Options, + iconLayout: GeoObjectTypes.Options, + iconShape: GeoObjectTypes.Options, + interactivityModel: GeoObjectTypes.Options, preset: GeoObjectTypes.Options, }; @@ -44,7 +52,7 @@ export class YMap { this.recalcZoomAndCenter(props); } - async findAddress(marker: YMapMarker) { + async findAddress(marker: YMapMarkerPrivate) { try { const res = await window.ymaps.geocode(marker.address, {results: 1}); const geoObject = res.geoObjects.get(0); @@ -58,7 +66,7 @@ export class YMap { } catch {} // If error - placemark will not be displayed } - findCoordinate(marker: YMapMarker) { + findCoordinate(marker: YMapMarkerPrivate) { const geoObject = new window.ymaps.Placemark(marker.coordinate, {}); this.coords.push(marker.coordinate as Coordinate); @@ -66,7 +74,7 @@ export class YMap { this.ymap.geoObjects.add(geoObject); } - private drawPlaceMarkStyle(geoObject: Ymaps.GeoObject, marker: YMapMarker) { + private drawPlaceMarkStyle(geoObject: Ymaps.GeoObject, marker: YMapMarkerPrivate) { const {iconColor, preset = DEFAULT_PLACEMARKS_PRESET} = marker.label || {}; let localIconColor: string | undefined = iconColor; @@ -75,16 +83,18 @@ export class YMap { localIconColor = DEFAULT_PLACEMARKS_COLOR; } - Object.entries({...marker.label, iconColor: localIconColor, preset}).forEach( - ([key, value]) => { - const geoObjectParamType: GeoObjectTypes | undefined = - geoObjectPropsAndOptions[key as keyof YMapMarkerLabel]; + Object.entries({ + ...marker.label, + iconColor: localIconColor, + preset, + }).forEach(([key, value]) => { + const geoObjectParamType: GeoObjectTypes | undefined = + geoObjectPropsAndOptions[key as keyof YMapMarkerLabelPrivate]; - if (value && geoObjectParamType) { - geoObject[geoObjectParamType].set(key, value); - } - }, - ); + if (value && geoObjectParamType) { + geoObject[geoObjectParamType].set(key, value); + } + }); } private recalcZoomAndCenter(props: PlacemarksProps) { diff --git a/src/components/Map/YMap/YandexMap.tsx b/src/components/Map/YMap/YandexMap.tsx index 89c521697..862abe318 100644 --- a/src/components/Map/YMap/YandexMap.tsx +++ b/src/components/Map/YMap/YandexMap.tsx @@ -6,7 +6,7 @@ import debounce from 'lodash/debounce'; import {LocaleContext} from '../../../context/localeContext/localeContext'; import {MapsContext} from '../../../context/mapsContext/mapsContext'; import {MobileContext} from '../../../context/mobileContext'; -import {YMapProps} from '../../../models'; +import {YMapMarkerLabelPrivate, YMapMarkerPrivate, YMapProps} from '../../../models'; import {block} from '../../../utils'; import ErrorWrapper from '../../ErrorWrapper/ErrorWrapper'; import {getMapHeight} from '../helpers'; @@ -23,8 +23,21 @@ const DEFAULT_ZOOM = 9; // The real center of the map will be calculated later, using the coordinates of the markers const INITIAL_CENTER = [0, 0]; +const BALLOON_DISABLING_MARKER_OPTIONS: YMapMarkerLabelPrivate = { + cursor: 'drag', + interactivityModel: 'default#silent', +}; + const YandexMap = (props: YMapProps) => { - const {markers, zoom, id, className} = props; + const { + markers, + zoom, + id, + disableControls = false, + disableBalloons = false, + className, + forceAspectRatio = true, + } = props; const {apiKey, scriptSrc, nonce} = React.useContext(MapsContext); const isMobile = React.useContext(MobileContext); @@ -56,8 +69,13 @@ const YandexMap = (props: YMapProps) => { { center: INITIAL_CENTER, zoom: zoom || DEFAULT_ZOOM, + controls: disableControls ? [] : undefined, + }, + { + autoFitToViewport: 'always', + suppressMapOpenBlock: disableControls, + yandexMapDisablePoiInteractivity: disableControls, }, - {autoFitToViewport: 'always'}, ), ref.current, ), @@ -66,9 +84,23 @@ const YandexMap = (props: YMapProps) => { setLoading(false); })(); - }, [apiKey, lang, scriptSrc, containerId, zoom, nonce, attemptsIndex, setLoading]); + }, [ + apiKey, + lang, + scriptSrc, + containerId, + zoom, + nonce, + attemptsIndex, + setLoading, + disableControls, + ]); React.useEffect(() => { + if (!forceAspectRatio) { + return; + } + const updateSize = debounce(() => { if (ref.current) { setHeight(Math.round(getMapHeight(ref.current.offsetWidth, isMobile))); @@ -78,23 +110,31 @@ const YandexMap = (props: YMapProps) => { updateSize(); window.addEventListener('resize', updateSize, {passive: true}); + // eslint-disable-next-line consistent-return return () => { window.removeEventListener('resize', updateSize); }; - }, [markers, ymap, setYmaps, isMobile]); + }, [isMobile, forceAspectRatio]); React.useEffect(() => { if (ymap) { // show with computed center and placemarks const showPlacemarks = async () => { - await ymap.showPlacemarks({markers, zoom}); + const privateMarkers: YMapMarkerPrivate[] = disableBalloons + ? markers.map(({label, ...marker}) => ({ + ...marker, + label: {...label, ...BALLOON_DISABLING_MARKER_OPTIONS}, + })) + : markers; + + await ymap.showPlacemarks({markers: privateMarkers, zoom}); setReady(true); }; showPlacemarks(); } - }); + }, [ymap, markers, zoom, disableBalloons]); if (!markers) return null; diff --git a/src/components/Map/__stories__/Map.mdx b/src/components/Map/__stories__/Map.mdx index f5d7d284a..b9c480cb8 100644 --- a/src/components/Map/__stories__/Map.mdx +++ b/src/components/Map/__stories__/Map.mdx @@ -18,6 +18,8 @@ For a detailed usage guide of the Map component, see [Map Usage](https://github. `className?: string` — Optional CSS class name +`forceAspectRatio?: boolean` — Determines whether map's aspect ratio is forced autmatically (16:9 for Desktop, 4:3 for Mobile) (`true` by default) + ### Google Maps (GMapProps) `address: string` — Target address to display on the map (required) @@ -28,6 +30,10 @@ For a detailed usage guide of the Map component, see [Map Usage](https://github. `id: string` — Unique identifier for the map instance (required) +`disableControls?: boolean` - If `true`, hides map's default controls (`false` by default) + +`disableBalloons?: boolean` - If `true`, disables info ballon opening when clicking on a marker (`false` by default) + #### YMapMarker Interface `address?: string` — Optional string address for the marker @@ -44,6 +50,18 @@ For a detailed usage guide of the Map component, see [Map Usage](https://github. `iconColor?: string` — Optional color for the marker icon +`iconImageHref?: string` - Optional URL for marker's custom icon image file + +`iconImageSize?: [number, number]` - Optional dimensions of custom icon + +`iconImageOffset?: [number, number]` - Optional custom icon's offset relative to it's anchor point + +`iconImageClipRect?: [[number, number], [number, number]]` - Optional coordinates of custom icon's displayed rectangular area, in pixels + +`iconLayout?: 'default#image'` - Required to use custom icons for a marker, otherwise optional + +`iconShape?: Record` - Optional active area shape for custom icon. Refer to documentation [e.g. Circle](https://yandex.ru/dev/jsapi-v2-1/doc/ru/v2-1/ref/reference/shape.Circle) + `preset?: string` — Optional preset style for the marker diff --git a/src/components/Map/__stories__/Map.stories.scss b/src/components/Map/__stories__/Map.stories.scss new file mode 100644 index 000000000..ddd94ce28 --- /dev/null +++ b/src/components/Map/__stories__/Map.stories.scss @@ -0,0 +1,10 @@ +.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 936671e26..dff9200fc 100644 --- a/src/components/Map/__stories__/Map.stories.tsx +++ b/src/components/Map/__stories__/Map.stories.tsx @@ -10,7 +10,9 @@ import {ApiKeyInput} from './ApiKeyInput'; import data from './data.json'; -const maxMapWidth = '500px'; +import './Map.stories.scss'; + +const maxMapWidth = 500; export default { component: Map, @@ -42,6 +44,25 @@ const GoogleMapTemplate: StoryFn = (args: MapProps) => ( export const GoogleMap = GoogleMapTemplate.bind({}); export const YMap = YMapTemplate.bind({}); +export const YMapHiddenControls = YMapTemplate.bind({}); +export const YMapHiddenBalloons = YMapTemplate.bind({}); +export const YMapCustomMarkers = YMapTemplate.bind({}); + +YMapHiddenControls.storyName = 'Y Map (Hidden Controls)'; +YMapHiddenBalloons.storyName = 'Y Map (Hidden Balloons)'; +YMapCustomMarkers.storyName = 'Y Map (Custom Markers)'; -YMap.args = data.ymap; GoogleMap.args = data.gmap; +YMap.args = data.ymap; + +YMapHiddenControls.args = { + ...data.ymap, + disableControls: true, +}; + +YMapHiddenBalloons.args = { + ...data.ymap, + disableBalloons: true, +}; + +YMapCustomMarkers.args = data.ymapCustomMarkers as MapProps; diff --git a/src/components/Map/__stories__/data.json b/src/components/Map/__stories__/data.json index bfd8bcc6b..9fe4155d8 100644 --- a/src/components/Map/__stories__/data.json +++ b/src/components/Map/__stories__/data.json @@ -24,6 +24,38 @@ } ] }, + "ymapCustomMarkers": { + "id": "common-places", + "markers": [ + { + "address": "Moscow Arbat", + "label": { + "iconLayout": "default#image", + "iconImageHref": "/story-assets/map-mark.svg", + "iconImageSize": [24, 24], + "iconImageOffset": [-12, -24] + } + }, + { + "address": "Moscow, Red square", + "label": { + "iconLayout": "default#image", + "iconImageHref": "/story-assets/map-mark.svg", + "iconImageSize": [24, 24], + "iconImageOffset": [-12, -24] + } + }, + { + "coordinate": [55.733974, 37.587093], + "label": { + "iconLayout": "default#image", + "iconImageHref": "/story-assets/map-mark.svg", + "iconImageSize": [24, 24], + "iconImageOffset": [-12, -24] + } + } + ] + }, "gmap": { "address": "Anthony Fokkerweg 1, 1059 CM Amsterdam" } diff --git a/src/components/index.ts b/src/components/index.ts index c873ebd11..389ac05e7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -26,6 +26,7 @@ export {default as ImageBase} from './ImageBase/ImageBase'; export {default as InnerForm} from './InnerForm/InnerForm'; export {default as Link} from './Link/Link'; export {default as Links} from './Links/Links'; +export {default as Map} from './Map/Map'; export {default as Media} from './Media/Media'; export {default as MetaInfo} from './MetaInfo/MetaInfo'; export {default as OutsideClick} from './OutsideClick/OutsideClick'; diff --git a/src/internal-typings/global.d.ts b/src/internal-typings/global.d.ts index ff2db76e7..ea8c450b6 100644 --- a/src/internal-typings/global.d.ts +++ b/src/internal-typings/global.d.ts @@ -45,10 +45,12 @@ declare namespace Ymaps { export class GeoObject { properties: { - set: (objectName: string, value: string) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set: (objectName: string, value: any) => void; }; options: { - set: (objectName: string, value: string) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set: (objectName: string, value: any) => void; }; } diff --git a/src/models/constructor-items/blocks.ts b/src/models/constructor-items/blocks.ts index f6b2b187e..f7a2ce411 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: MapProps; + map: Omit; } export interface InfoBlockProps { diff --git a/src/models/constructor-items/common.ts b/src/models/constructor-items/common.ts index 8fad42078..09bcadd91 100644 --- a/src/models/constructor-items/common.ts +++ b/src/models/constructor-items/common.ts @@ -328,6 +328,7 @@ export type Coordinate = number[]; export interface MapBaseProps { zoom?: number; className?: string; + forceAspectRatio?: boolean; } export interface GMapProps extends MapBaseProps { @@ -336,6 +337,8 @@ export interface GMapProps extends MapBaseProps { export interface YMapProps extends MapBaseProps { markers: YMapMarker[]; + disableControls?: boolean; + disableBalloons?: boolean; id: string; } @@ -349,9 +352,25 @@ export interface YMapMarkerLabel { iconCaption?: string; iconContent?: string; iconColor?: string; + iconImageHref?: string; + iconImageSize?: [number, number]; + iconImageOffset?: [number, number]; + iconImageClipRect?: [[number, number], [number, number]]; + iconLayout?: 'default#image'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iconShape?: Record; preset?: string; } +export interface YMapMarkerPrivate extends YMapMarker { + label?: YMapMarkerLabelPrivate; +} + +export interface YMapMarkerLabelPrivate extends YMapMarkerLabel { + cursor?: string; + interactivityModel?: string; +} + export type MapProps = GMapProps | YMapProps; export type ThemedMediaProps = ThemeSupporting; diff --git a/src/schema/validators/common.ts b/src/schema/validators/common.ts index 89a398d21..9a9f326f5 100644 --- a/src/schema/validators/common.ts +++ b/src/schema/validators/common.ts @@ -641,6 +641,13 @@ export const MediaProps = { }, }; +const YMapXY = { + type: 'array', + items: {type: 'number'}, + minItems: 2, + maxItems: 2, +}; + export const YMapMarkerLabel = { type: 'object', required: [], @@ -654,6 +661,24 @@ export const YMapMarkerLabel = { iconColor: { type: 'string', }, + iconImageHref: { + type: 'string', + }, + iconImageSize: YMapXY, + iconImageOffset: YMapXY, + iconImageClipRect: { + type: 'array', + items: YMapXY, + minItems: 2, + maxItems: 2, + }, + iconLayout: { + type: 'string', + }, + iconShape: { + type: 'object', + additionalProperties: true, + }, preset: { type: 'string', }, @@ -690,6 +715,15 @@ export const MapProps = { type: 'array', items: YMapMarker, }, + forceAspectRatio: { + type: 'boolean', + }, + disableControls: { + type: 'boolean', + }, + disableBalloons: { + type: 'boolean', + }, }; export const BorderProps = {