Skip to content

Commit 1509dda

Browse files
committed
Fix an order-of-operations bug in the findScreenBearing method + refactor for testability + add tests to findScreenBearing
1 parent 84ebd29 commit 1509dda

File tree

5 files changed

+183
-134
lines changed

5 files changed

+183
-134
lines changed

src/index.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,19 @@ describe('mapFitFeatures', () => {
5252
center: [153.03229437366502, -27.588626358968774],
5353
});
5454
});
55+
56+
it('should fit the map to a set of features with a preferred bearing', () => {
57+
// This ficture set produces a bearing of 67.26319164329749, but if we set a preferred bearing of 210, it should be 247.2631916432975
58+
const result = mapFitFeatures(fixturePoints1, [800, 600], {
59+
preferredBearing: 210,
60+
});
61+
62+
expect(result).toEqual({
63+
bearing: 247.2631916432975,
64+
zoom: 10.28961353474488,
65+
center: [153.0370867491144, -27.525132573088545],
66+
});
67+
});
5568
});
69+
70+

src/index.ts

Lines changed: 3 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,8 @@
11
import { SphericalMercator } from '@mapbox/sphericalmercator';
22
import * as turf from '@turf/turf';
3-
import type { Polygon, Feature, FeatureCollection, Position, LineString } from 'geojson';
4-
5-
type XY = [number, number];
6-
type LngLat = [number, number];
7-
8-
interface mapFitOptions {
9-
tileSize?: number;
10-
preferredBearing?: number;
11-
maxZoom?: number;
12-
floatZoom?: boolean;
13-
padding?: mapFitPadding;
14-
}
15-
16-
interface mapFitPadding {
17-
left?: number;
18-
right?: number;
19-
top?: number;
20-
bottom?: number;
21-
}
22-
23-
interface mapFitResult {
24-
bearing: number;
25-
zoom: number;
26-
center: LngLat;
27-
}
28-
29-
interface rectangleOrientation {
30-
shortSide: Feature<LineString> | undefined;
31-
longSide: Feature<LineString> | undefined;
32-
}
33-
34-
interface boundingOrientation {
35-
bearing: number | undefined;
36-
orientation: rectangleOrientation;
37-
envelope: Feature<Polygon> | undefined;
38-
}
3+
import type { Polygon, Feature, FeatureCollection, LineString } from 'geojson';
4+
import { findScreenCenter, findScreenBearing, findScreenZoom} from './screen';
5+
import { XY, mapFitPadding, mapFitOptions, mapFitResult, rectangleOrientation, boundingOrientation } from './types';
396

407
function mapFitFeatures(
418
features: FeatureCollection,
@@ -84,104 +51,6 @@ function mapFitFeatures(
8451
return { bearing, zoom, center };
8552
}
8653

87-
function findScreenZoom(
88-
paddedScreenDimensions: XY,
89-
paddedScreenRatio: number,
90-
boundingRectangleOrientation: rectangleOrientation,
91-
maxZoom: number,
92-
floatZoom: boolean,
93-
merc: SphericalMercator,
94-
): number {
95-
const { shortSide, longSide } = boundingRectangleOrientation;
96-
const longSideCoords = turf.getCoords(longSide!);
97-
const shortSideCoords = turf.getCoords(shortSide!);
98-
99-
// We need to determine the ratio required for the zoom level. To do this we are going to approximate the length
100-
// of the longest and shortest sides of the polygon in pixels (This doesn't account for projection distortion but is
101-
// a good estimation)
102-
const longPx: [XY, XY] = [merc.px(longSideCoords[0], maxZoom), merc.px(longSideCoords[1], maxZoom)];
103-
const shortPx: [XY, XY] = [merc.px(shortSideCoords[0], maxZoom), merc.px(shortSideCoords[1], maxZoom)];
104-
105-
// Because these points aren't aligned to the axis, we use the Pythagorean theorem to calculate the distance
106-
const longPxX = longPx[0][0] - longPx[1][0];
107-
const longPxY = longPx[0][1] - longPx[1][1];
108-
const shortPxX = shortPx[0][0] - shortPx[1][0];
109-
const shortPxY = shortPx[0][1] - shortPx[1][1];
110-
const longPxDistance = Math.sqrt(Math.pow(longPxX, 2) + Math.pow(longPxY, 2));
111-
const shortPxDistance = Math.sqrt(Math.pow(shortPxX, 2) + Math.pow(shortPxY, 2));
112-
113-
let xPx = longPxDistance;
114-
let yPx = shortPxDistance;
115-
116-
// If the screen is taller than it is wide, swap the x and y values
117-
if (paddedScreenRatio < 1) {
118-
xPx = shortPxDistance;
119-
yPx = longPxDistance;
120-
}
121-
122-
const ratios: XY = [Math.abs(xPx / paddedScreenDimensions[0]), Math.abs(yPx / paddedScreenDimensions[1])];
123-
const zoom = Math.min(maxZoom - Math.log(ratios[0]) / Math.log(2), maxZoom - Math.log(ratios[1]) / Math.log(2));
124-
return floatZoom ? zoom : Math.floor(zoom);
125-
}
126-
127-
function findScreenBearing(boundingRectangleBearing: number, preferredBearing: number, screenRatio: number): number {
128-
let bearing = boundingRectangleBearing;
129-
// Rotate the bearing by 90 degrees if the screen is wider than it is tall
130-
if (screenRatio > 1) {
131-
bearing = bearing + (90 % 360);
132-
}
133-
134-
// Rotate the bearing 180 degrees if the preferred bearing is on the opposite side of the screen
135-
if (bearing < preferredBearing - (90 % 360) || bearing > preferredBearing + (90 % 360)) {
136-
bearing = (bearing + 180) % 360;
137-
}
138-
139-
return bearing;
140-
}
141-
142-
function findScreenCenter(
143-
boundingRectangle: Feature<Polygon>,
144-
bearing: number,
145-
zoom: number,
146-
padding: mapFitPadding,
147-
merc: SphericalMercator,
148-
) {
149-
const { left = 0, right = 0, top = 0, bottom = 0 } = padding;
150-
151-
// Use the bounding rectangle's pixel location to calculate the centre of the
152-
// map. This allows us to account for mercator projection distortion.
153-
const coords = turf.getCoords(boundingRectangle);
154-
const uniqCoords = coords[0].reduce((uniq: Position[], coord: [number, number]) => {
155-
if (!uniq.find((c) => c[0] === coord[0] && c[1] === coord[1])) {
156-
uniq.push(coord);
157-
}
158-
return uniq;
159-
}, []);
160-
161-
const sumCoords = uniqCoords.reduce(
162-
(acc: [number, number], coord: [number, number]) => {
163-
const [x, y] = merc.px(coord as LngLat, zoom);
164-
acc[0] = acc[0] + x;
165-
acc[1] = acc[1] + y;
166-
return acc;
167-
},
168-
[0, 0],
169-
);
170-
171-
const midX = sumCoords[0] / uniqCoords.length;
172-
const midY = sumCoords[1] / uniqCoords.length;
173-
174-
const xPaddingOffset = right - left;
175-
const yPaddingOffset = bottom - top;
176-
177-
const bearingRadians = bearing * (Math.PI / 180);
178-
179-
const centerXOffset = xPaddingOffset * Math.cos(bearingRadians) - yPaddingOffset * Math.sin(bearingRadians);
180-
const centerYOffset = xPaddingOffset * Math.sin(bearingRadians) + yPaddingOffset * Math.cos(bearingRadians);
181-
182-
return merc.ll([midX + centerXOffset, midY + centerYOffset], zoom);
183-
}
184-
18554
export function minimumBoundingRectangle(geoJsonInput: turf.AllGeoJSON): {
18655
boundsOrientation: boundingOrientation;
18756
boundingRectangle: Feature<Polygon>;

src/screen.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { findScreenBearing } from './screen';
2+
3+
describe('findScreenBearing', () => {
4+
it('should return the north-oriented bearing of the long side of the bounding rectangle if the screen is portrait', () => {
5+
const result = findScreenBearing(23.564, 0, 0.5);
6+
7+
expect(result).toEqual(23.564);
8+
});
9+
10+
it('should return the west-oriented bearing of the most long side of the bounding rectangle if the screen is portrait and preferredBearing 270 is given', () => {
11+
const result = findScreenBearing(23.564,270, 0.5);
12+
13+
expect(result).toEqual(203.564);
14+
});
15+
16+
it('should return the bearing 90 off the most north-oriented long side of the bounding rectangle if the screen is landscape', () => {
17+
const result = findScreenBearing(23.564, 0, 1.2);
18+
19+
expect(result).toEqual(293.56399999999996);
20+
});
21+
22+
it('should return the south-oriented bearing of the most long side of the bounding rectangle if the screen is landscape and preferredBearing 180 is given', () => {
23+
const result = findScreenBearing(23.564, 180, 1.2);
24+
25+
expect(result).toEqual(113.564);
26+
});
27+
});

src/screen.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import * as turf from '@turf/turf';
2+
import type { Feature, Polygon, Position } from 'geojson';
3+
import { SphericalMercator } from '@mapbox/sphericalmercator';
4+
import { XY, LngLat, mapFitPadding, rectangleOrientation } from './types';
5+
6+
export function findScreenZoom(
7+
paddedScreenDimensions: XY,
8+
paddedScreenRatio: number,
9+
boundingRectangleOrientation: rectangleOrientation,
10+
maxZoom: number,
11+
floatZoom: boolean,
12+
merc: SphericalMercator,
13+
): number {
14+
const { shortSide, longSide } = boundingRectangleOrientation;
15+
const longSideCoords = turf.getCoords(longSide!);
16+
const shortSideCoords = turf.getCoords(shortSide!);
17+
18+
// We need to determine the ratio required for the zoom level. To do this we are going to approximate the length
19+
// of the longest and shortest sides of the polygon in pixels (This doesn't account for projection distortion but is
20+
// a good estimation)
21+
const longPx: [XY, XY] = [merc.px(longSideCoords[0], maxZoom), merc.px(longSideCoords[1], maxZoom)];
22+
const shortPx: [XY, XY] = [merc.px(shortSideCoords[0], maxZoom), merc.px(shortSideCoords[1], maxZoom)];
23+
24+
// Because these points aren't aligned to the axis, we use the Pythagorean theorem to calculate the distance
25+
const longPxX = longPx[0][0] - longPx[1][0];
26+
const longPxY = longPx[0][1] - longPx[1][1];
27+
const shortPxX = shortPx[0][0] - shortPx[1][0];
28+
const shortPxY = shortPx[0][1] - shortPx[1][1];
29+
const longPxDistance = Math.sqrt(Math.pow(longPxX, 2) + Math.pow(longPxY, 2));
30+
const shortPxDistance = Math.sqrt(Math.pow(shortPxX, 2) + Math.pow(shortPxY, 2));
31+
32+
let xPx = longPxDistance;
33+
let yPx = shortPxDistance;
34+
35+
// If the screen is taller than it is wide, swap the x and y values
36+
if (paddedScreenRatio < 1) {
37+
xPx = shortPxDistance;
38+
yPx = longPxDistance;
39+
}
40+
41+
const ratios: XY = [Math.abs(xPx / paddedScreenDimensions[0]), Math.abs(yPx / paddedScreenDimensions[1])];
42+
const zoom = Math.min(maxZoom - Math.log(ratios[0]) / Math.log(2), maxZoom - Math.log(ratios[1]) / Math.log(2));
43+
return floatZoom ? zoom : Math.floor(zoom);
44+
}
45+
46+
export function findScreenBearing(boundingRectangleBearing: number, preferredBearing: number, screenRatio: number): number {
47+
let bearing = boundingRectangleBearing;
48+
// Rotate the bearing by 90 degrees if the screen is wider than it is tall
49+
if (screenRatio > 1) {
50+
bearing = bearing + (90 % 360);
51+
}
52+
53+
// Rotate the bearing 180 degrees if the preferred bearing is on the opposite side of the screen
54+
if (bearing < (preferredBearing - 90) % 360 || bearing > (preferredBearing + 90) % 360) {
55+
bearing = (bearing + 180) % 360;
56+
}
57+
58+
return bearing;
59+
}
60+
61+
export function findScreenCenter(
62+
boundingRectangle: Feature<Polygon>,
63+
bearing: number,
64+
zoom: number,
65+
padding: mapFitPadding,
66+
merc: SphericalMercator,
67+
) {
68+
const { left = 0, right = 0, top = 0, bottom = 0 } = padding;
69+
70+
// Use the bounding rectangle's pixel location to calculate the centre of the
71+
// map. This allows us to account for mercator projection distortion.
72+
const coords = turf.getCoords(boundingRectangle);
73+
const uniqCoords = coords[0].reduce((uniq: Position[], coord: [number, number]) => {
74+
if (!uniq.find((c) => c[0] === coord[0] && c[1] === coord[1])) {
75+
uniq.push(coord);
76+
}
77+
return uniq;
78+
}, []);
79+
80+
const sumCoords = uniqCoords.reduce(
81+
(acc: [number, number], coord: [number, number]) => {
82+
const [x, y] = merc.px(coord as LngLat, zoom);
83+
acc[0] = acc[0] + x;
84+
acc[1] = acc[1] + y;
85+
return acc;
86+
},
87+
[0, 0],
88+
);
89+
90+
const midX = sumCoords[0] / uniqCoords.length;
91+
const midY = sumCoords[1] / uniqCoords.length;
92+
93+
const xPaddingOffset = right - left;
94+
const yPaddingOffset = bottom - top;
95+
96+
const bearingRadians = bearing * (Math.PI / 180);
97+
98+
const centerXOffset = xPaddingOffset * Math.cos(bearingRadians) - yPaddingOffset * Math.sin(bearingRadians);
99+
const centerYOffset = xPaddingOffset * Math.sin(bearingRadians) + yPaddingOffset * Math.cos(bearingRadians);
100+
101+
return merc.ll([midX + centerXOffset, midY + centerYOffset], zoom);
102+
}

src/types.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Feature, LineString, Polygon } from 'geojson';
2+
3+
export type XY = [number, number];
4+
export type LngLat = [number, number];
5+
6+
export interface mapFitOptions {
7+
tileSize?: number;
8+
preferredBearing?: number;
9+
maxZoom?: number;
10+
floatZoom?: boolean;
11+
padding?: mapFitPadding;
12+
}
13+
14+
export interface mapFitPadding {
15+
left?: number;
16+
right?: number;
17+
top?: number;
18+
bottom?: number;
19+
}
20+
21+
export interface mapFitResult {
22+
bearing: number;
23+
zoom: number;
24+
center: LngLat;
25+
}
26+
27+
export interface rectangleOrientation {
28+
shortSide: Feature<LineString> | undefined;
29+
longSide: Feature<LineString> | undefined;
30+
}
31+
32+
export interface boundingOrientation {
33+
bearing: number | undefined;
34+
orientation: rectangleOrientation;
35+
envelope: Feature<Polygon> | undefined;
36+
}

0 commit comments

Comments
 (0)