Skip to content

Commit 7b39602

Browse files
authored
feat(Map): add margins (#1307)
1 parent b5a404d commit 7b39602

File tree

11 files changed

+210
-42
lines changed

11 files changed

+210
-42
lines changed

memory-bank/usage/map.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,10 @@ graph TD
7272
- `id`: Unique identifier for the map instance (required)
7373
- `zoom`: Optional zoom level (inherited from MapBaseProps)
7474
- `className`: Optional CSS class name (inherited from MapBaseProps)
75+
- `forceAspectRatio`: Optional boolean to force aspect ratio (16:9 for Desktop, 4:3 for Mobile), `true` by default (inherited from MapBaseProps)
7576
- `disableControls`: Optional boolean to hide map controls (Yandex Maps only), `false` by default
7677
- `disableBalloons`: Optional boolean to disable info balloons (Yandex Maps only), `false` by default
78+
- `areaMargin:` - Optional offset (in pixels) for the marked area of the map relative to the map's container (`30` by default)
7779

7880
#### MapBaseProps (Common Props)
7981

src/components/Map/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Map
1414

1515
`disableBalloons?: boolean` - If `true`, disables info ballon opening when clicking on a marker. Only for `Yandex maps`. `false` by default
1616

17+
`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
18+
1719
`markers?: object[]` - Description for placemarkers. You need to use it for `Yandex maps`. Specify the parameters given below.
1820

1921
- `address?: string` — Place name, address

src/components/Map/YMap/YMap.ts

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {YMapMarkerLabelPrivate, YMapMarkerPrivate, YMapProps} from '../../../models';
22
import {Coordinate} from '../../../models/constructor-items/common';
33

4+
import {ParsedMargin, calculateMapParamsWithMarginAndZoom, parseMargin} from './utils';
5+
46
enum GeoObjectTypes {
57
Properties = 'properties',
68
Options = 'options',
@@ -26,7 +28,7 @@ const geoObjectPropsAndOptions: Record<keyof YMapMarkerLabelPrivate, GeoObjectTy
2628
preset: GeoObjectTypes.Options,
2729
};
2830

29-
type PlacemarksProps = Pick<YMapProps, 'zoom' | 'markers'>;
31+
type PlacemarksProps = Pick<YMapProps, 'zoom' | 'markers' | 'areaMargin'>;
3032

3133
export class YMap {
3234
private ymap: Ymaps.Map;
@@ -97,45 +99,76 @@ export class YMap {
9799
});
98100
}
99101

102+
// eslint-disable-next-line complexity
100103
private recalcZoomAndCenter(props: PlacemarksProps) {
101104
const coordsLength = this.coords.length;
102-
const {zoom = 0} = props;
105+
const {zoom = 0, areaMargin} = props;
103106

104107
if (!coordsLength) {
105108
return;
106109
}
107110

108-
let leftBottom = [Infinity, Infinity],
109-
rightTop = [-Infinity, -Infinity];
111+
const utils = window.ymaps.util.bounds;
110112

111-
this.coords.forEach((point) => {
112-
leftBottom = [Math.min(leftBottom[0], point[0]), Math.min(leftBottom[1], point[1])];
113-
rightTop = [Math.max(rightTop[0], point[0]), Math.max(rightTop[1], point[1])];
114-
});
113+
const [leftTop, rightBottom] = utils.fromPoints(this.coords);
115114

116115
let newMapParams = {
117116
zoom,
118117
center: [],
119118
};
120119

121-
if (zoom) {
122-
// compute only the center
123-
newMapParams.center = window.ymaps.util.bounds.getCenter([leftBottom, rightTop]);
124-
} else {
125-
newMapParams = window.ymaps.util.bounds.getCenterAndZoom(
126-
[leftBottom, rightTop],
127-
[this.mapRef?.clientWidth, this.mapRef?.clientHeight],
128-
undefined,
129-
{margin: DEFAULT_MAP_CONTROL_BUTTON_HEIGHT},
130-
);
120+
const parsedAreaMargin = areaMargin
121+
? parseMargin(areaMargin)
122+
: ([0, 0, 0, 0] as ParsedMargin);
123+
124+
const hasZoom = Boolean(zoom);
125+
const hasAreaMargin = parsedAreaMargin.some(Boolean);
126+
const containerSize = [
127+
this.mapRef?.clientWidth ?? 0,
128+
this.mapRef?.clientHeight ?? 0,
129+
] as Coordinate;
130+
131+
switch (true) {
132+
case hasAreaMargin && hasZoom:
133+
// calculate center and zoom in accordace with current zoom and margin
134+
newMapParams = calculateMapParamsWithMarginAndZoom(
135+
[leftTop, rightBottom],
136+
zoom,
137+
parsedAreaMargin,
138+
containerSize,
139+
);
140+
break;
141+
case hasAreaMargin:
142+
// calculate center and zoom with custom margin
143+
newMapParams = utils.getCenterAndZoom(
144+
[leftTop, rightBottom],
145+
containerSize,
146+
undefined,
147+
{margin: areaMargin, preciseZoom: true},
148+
);
149+
break;
150+
case hasZoom:
151+
// calculate only center
152+
newMapParams.center = utils.getCenter([leftTop, rightBottom]);
153+
break;
154+
default:
155+
// calculate center and zoom with default margin
156+
newMapParams = utils.getCenterAndZoom(
157+
[leftTop, rightBottom],
158+
containerSize,
159+
undefined,
160+
{margin: DEFAULT_MAP_CONTROL_BUTTON_HEIGHT},
161+
);
131162
}
132163

133164
this.ymap.setCenter(newMapParams.center);
134165

135166
// Use default zoom for one placemark
136-
if (coordsLength > 1 && !zoom) {
137-
this.ymap.setZoom(newMapParams.zoom);
167+
if (coordsLength <= 1 && !hasAreaMargin && hasZoom) {
168+
return;
138169
}
170+
171+
this.ymap.setZoom(newMapParams.zoom);
139172
}
140173

141174
private clearOldPlacemarks() {

src/components/Map/YMap/YandexMap.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const YandexMap = (props: YMapProps) => {
3535
id,
3636
disableControls = false,
3737
disableBalloons = false,
38+
areaMargin,
3839
className,
3940
forceAspectRatio = true,
4041
} = props;
@@ -127,14 +128,14 @@ const YandexMap = (props: YMapProps) => {
127128
}))
128129
: markers;
129130

130-
await ymap.showPlacemarks({markers: privateMarkers, zoom});
131+
await ymap.showPlacemarks({markers: privateMarkers, zoom, areaMargin});
131132

132133
setReady(true);
133134
};
134135

135136
showPlacemarks();
136137
}
137-
}, [ymap, markers, zoom, disableBalloons]);
138+
}, [ymap, markers, zoom, disableBalloons, areaMargin]);
138139

139140
if (!markers) return null;
140141

src/components/Map/YMap/utils.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {Coordinate, YMapMargin} from '../../../models';
2+
3+
export type ParsedMargin = [top: number, right: number, bottom: number, left: number];
4+
5+
export const parseMargin = (margin: YMapMargin): ParsedMargin => {
6+
if (!Array.isArray(margin)) {
7+
return [margin, margin, margin, margin];
8+
}
9+
10+
if (margin.length === 2) {
11+
return [margin[0], margin[1], margin[0], margin[1]];
12+
}
13+
14+
return margin;
15+
};
16+
17+
export const calcPixelBounds = (
18+
[leftTop, rightBottom]: [Coordinate, Coordinate],
19+
zoom: number,
20+
containerSize: Coordinate,
21+
) => {
22+
const utils = window.ymaps.util.bounds;
23+
24+
let [[leftPx, topPx], [rightPx, bottomPx]] = utils.toGlobalPixelBounds(
25+
[leftTop, rightBottom],
26+
zoom,
27+
) as [Coordinate, Coordinate];
28+
29+
// fall back to container size in case there is only one marker and area is 0
30+
if (rightPx - leftPx <= 0) {
31+
const halfX = containerSize[0] / 2;
32+
leftPx -= halfX;
33+
rightPx += halfX;
34+
}
35+
36+
if (bottomPx - topPx <= 0) {
37+
const halfY = containerSize[1] / 2;
38+
topPx -= halfY;
39+
bottomPx += halfY;
40+
}
41+
42+
return [
43+
[leftPx, topPx],
44+
[rightPx, bottomPx],
45+
];
46+
};
47+
48+
const calcNewZoom = (l: number, zoom: number, marginSum: number) => {
49+
return Math.log2((Math.pow(2, zoom) * (l - marginSum)) / l);
50+
};
51+
52+
export const calculateMapParamsWithMarginAndZoom = (
53+
[leftTop, rightBottom]: [Coordinate, Coordinate],
54+
zoom: number,
55+
areaMargin: ParsedMargin,
56+
containerSize: Coordinate,
57+
) => {
58+
const utils = window.ymaps.util.bounds;
59+
60+
// calculate pixel bounds with current zoom
61+
let [[leftPx, topPx], [rightPx, bottomPx]] = calcPixelBounds(
62+
[leftTop, rightBottom],
63+
zoom,
64+
containerSize,
65+
);
66+
67+
const [topMargin, rightMargin, bottomMargin, leftMargin] = areaMargin;
68+
69+
let zoomV: number;
70+
let zoomH: number;
71+
72+
// calculate new zoom value after margins are applied
73+
if (leftMargin && rightMargin) {
74+
zoomH = calcNewZoom(rightPx - leftPx, zoom, leftMargin + rightMargin);
75+
} else {
76+
zoomH = zoom;
77+
}
78+
79+
if (topMargin && bottomMargin) {
80+
zoomV = calcNewZoom(bottomPx - topPx, zoom, topMargin + bottomMargin);
81+
} else {
82+
zoomV = zoom;
83+
}
84+
85+
const newZoom = Math.min(zoomV, zoomH);
86+
87+
// calculate pixel bounds with new zoom
88+
[[leftPx, topPx], [rightPx, bottomPx]] = calcPixelBounds(
89+
[leftTop, rightBottom],
90+
newZoom,
91+
containerSize,
92+
);
93+
94+
// calculate new bounds (scale if both size are present, otherwise shift the map)
95+
if (leftMargin && rightMargin) {
96+
leftPx -= leftMargin;
97+
rightPx += rightMargin;
98+
} else if (leftMargin) {
99+
leftPx -= leftMargin;
100+
rightPx -= leftMargin;
101+
} else if (rightMargin) {
102+
leftPx += rightMargin;
103+
rightPx += rightMargin;
104+
}
105+
106+
if (topMargin && bottomMargin) {
107+
topPx -= topMargin;
108+
bottomPx += bottomMargin;
109+
} else if (topMargin) {
110+
topPx -= topMargin;
111+
bottomPx -= topMargin;
112+
} else if (bottomMargin) {
113+
topPx += bottomMargin;
114+
bottomPx += bottomMargin;
115+
}
116+
117+
// transform new bounds into coordinates
118+
const [newLeftTop, newRightBottom] = utils.fromGlobalPixelBounds(
119+
[
120+
[leftPx, topPx],
121+
[rightPx, bottomPx],
122+
],
123+
newZoom,
124+
);
125+
126+
return {
127+
center: utils.getCenter([newLeftTop, newRightBottom]),
128+
zoom: newZoom,
129+
};
130+
};

src/components/Map/__stories__/Map.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ For a detailed usage guide of the Map component, see [Map Usage](https://github.
3434

3535
`disableBalloons?: boolean` - If `true`, disables info ballon opening when clicking on a marker (`false` by default)
3636

37+
`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)
38+
3739
#### YMapMarker Interface
3840

3941
`address?: string` — Optional string address for the marker

src/components/Map/__stories__/Map.stories.scss

Lines changed: 0 additions & 10 deletions
This file was deleted.

src/components/Map/__stories__/Map.stories.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import {ApiKeyInput} from './ApiKeyInput';
1010

1111
import data from './data.json';
1212

13-
import './Map.stories.scss';
14-
1513
const maxMapWidth = 500;
1614

1715
export default {
@@ -47,22 +45,29 @@ export const YMap = YMapTemplate.bind({});
4745
export const YMapHiddenControls = YMapTemplate.bind({});
4846
export const YMapHiddenBalloons = YMapTemplate.bind({});
4947
export const YMapCustomMarkers = YMapTemplate.bind({});
48+
export const YMapAreaOffset = YMapTemplate.bind({});
5049

5150
YMapHiddenControls.storyName = 'Y Map (Hidden Controls)';
5251
YMapHiddenBalloons.storyName = 'Y Map (Hidden Balloons)';
5352
YMapCustomMarkers.storyName = 'Y Map (Custom Markers)';
53+
YMapAreaOffset.storyName = 'Y Map (Area Margin)';
5454

5555
GoogleMap.args = data.gmap;
56-
YMap.args = data.ymap;
56+
YMap.args = data.ymap as MapProps;
5757

5858
YMapHiddenControls.args = {
5959
...data.ymap,
6060
disableControls: true,
61-
};
61+
} as MapProps;
6262

6363
YMapHiddenBalloons.args = {
6464
...data.ymap,
6565
disableBalloons: true,
66-
};
66+
} as MapProps;
6767

6868
YMapCustomMarkers.args = data.ymapCustomMarkers as MapProps;
69+
70+
YMapAreaOffset.args = {
71+
...data.ymap,
72+
areaMargin: [0, 0, 0, 200],
73+
} as MapProps;

src/models/constructor-items/blocks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ export interface MediaBlockProps extends MediaBaseBlockProps, WithBorder {
292292
}
293293

294294
export interface MapBlockProps extends MediaBaseBlockProps, WithBorder {
295-
map: Omit<MapProps, 'forceAspectRatio'>;
295+
map: Omit<MapProps, 'forceAspectRatio' | 'areaOffset'>;
296296
}
297297

298298
export interface InfoBlockProps {

src/models/constructor-items/common.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ export interface BackgroundMediaProps extends MediaProps, Animatable, QAProps {
323323
mediaClassName?: string;
324324
}
325325

326-
export type Coordinate = number[];
326+
export type Coordinate = [number, number];
327327

328328
export interface MapBaseProps {
329329
zoom?: number;
@@ -335,10 +335,16 @@ export interface GMapProps extends MapBaseProps {
335335
address: string;
336336
}
337337

338+
export type YMapMargin =
339+
| number
340+
| [vertical: number, horizontal: number]
341+
| [top: number, right: number, bottom: number, left: number];
342+
338343
export interface YMapProps extends MapBaseProps {
339344
markers: YMapMarker[];
340345
disableControls?: boolean;
341346
disableBalloons?: boolean;
347+
areaMargin?: YMapMargin;
342348
id: string;
343349
}
344350

0 commit comments

Comments
 (0)