Skip to content

Commit 8ef8597

Browse files
Merge pull request #2739 from hadbn/fix-ellipse-harversine
Improved the quality of generated ellipses especially at higher latitudes
2 parents 8410246 + 20dbee7 commit 8ef8597

18 files changed

+5049
-3707
lines changed

packages/turf-ellipse/index.ts

Lines changed: 93 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import {
2-
degreesToRadians,
32
polygon,
43
isObject,
54
isNumber,
65
Coord,
76
Units,
7+
point,
8+
radiansToDegrees,
89
} from "@turf/helpers";
9-
import { rhumbDestination } from "@turf/rhumb-destination";
10+
import { destination } from "@turf/destination";
1011
import { transformRotate } from "@turf/transform-rotate";
1112
import { getCoord } from "@turf/invariant";
12-
import { GeoJsonProperties, Feature, Polygon } from "geojson";
13+
import { GeoJsonProperties, Feature, Polygon, Position } from "geojson";
1314

1415
/**
1516
* Takes a {@link Point} and calculates the ellipse polygon given two semi-axes expressed in variable units and steps for precision.
@@ -47,12 +48,11 @@ function ellipse(
4748
): Feature<Polygon> {
4849
// Optional params
4950
options = options || {};
50-
const steps = options.steps || 64;
51+
let steps = options.steps || 64;
5152
const units = options.units || "kilometers";
52-
const angle = options.angle || 0;
53+
let angle = options.angle || 0;
5354
const pivot = options.pivot || center;
5455
const properties = options.properties || {};
55-
5656
// validation
5757
if (!center) throw new Error("center is required");
5858
if (!xSemiAxis) throw new Error("xSemiAxis is required");
@@ -61,62 +61,99 @@ function ellipse(
6161
if (!isNumber(steps)) throw new Error("steps must be a number");
6262
if (!isNumber(angle)) throw new Error("angle must be a number");
6363

64-
const centerCoords = getCoord(center);
65-
if (units !== "degrees") {
66-
const xDest = rhumbDestination(center, xSemiAxis, 90, { units });
67-
const yDest = rhumbDestination(center, ySemiAxis, 0, { units });
68-
xSemiAxis = getCoord(xDest)[0] - centerCoords[0];
69-
ySemiAxis = getCoord(yDest)[1] - centerCoords[1];
70-
}
64+
const centerCoords = getCoord(
65+
transformRotate(point(getCoord(center)), angle, { pivot })
66+
);
67+
68+
angle = -90 + angle;
69+
70+
// Divide steps by 4 for one quadrant
71+
steps = Math.ceil(steps / 4);
72+
73+
let quadrantParameters = [];
74+
let parameters = [];
75+
76+
const a = xSemiAxis;
77+
const b = ySemiAxis;
78+
79+
// Gradient x intersect
80+
const c = b;
81+
82+
// Gradient of line
83+
const m = (a - b) / (Math.PI / 2);
84+
85+
// Area under line
86+
const A = ((a + b) * Math.PI) / 4;
87+
88+
// Weighting function
89+
const v = 0.5;
90+
91+
const k = steps;
7192

72-
const coordinates: number[][] = [];
73-
for (let i = 0; i < steps; i += 1) {
74-
const stepAngle = (i * -360) / steps;
75-
let x =
76-
(xSemiAxis * ySemiAxis) /
77-
Math.sqrt(
78-
Math.pow(ySemiAxis, 2) +
79-
Math.pow(xSemiAxis, 2) * Math.pow(getTanDeg(stepAngle), 2)
80-
);
81-
let y =
82-
(xSemiAxis * ySemiAxis) /
83-
Math.sqrt(
84-
Math.pow(xSemiAxis, 2) +
85-
Math.pow(ySemiAxis, 2) / Math.pow(getTanDeg(stepAngle), 2)
86-
);
87-
88-
if (stepAngle < -90 && stepAngle >= -270) x = -x;
89-
if (stepAngle < -180 && stepAngle >= -360) y = -y;
90-
if (units === "degrees") {
91-
const angleRad = degreesToRadians(angle);
92-
const newx = x * Math.cos(angleRad) + y * Math.sin(angleRad);
93-
const newy = y * Math.cos(angleRad) - x * Math.sin(angleRad);
94-
x = newx;
95-
y = newy;
93+
let w = 0;
94+
let x = 0;
95+
96+
for (let i = 0; i < steps; i++) {
97+
x += w;
98+
99+
if (m === 0) {
100+
// It's a circle, so use simplified c*w - A/k == 0
101+
w = A / k / c;
102+
} else {
103+
// Otherwise, use full (v*m)*w^2 + (m*x+c)*w - A/k == 0
104+
// Solve as quadratic ax^2 + bx + c = 0
105+
w =
106+
(-(m * x + c) +
107+
Math.sqrt(Math.pow(m * x + c, 2) - 4 * (v * m) * -(A / k))) /
108+
(2 * (v * m));
109+
}
110+
if (x != 0) {
111+
// easier to add it later to avoid having twice the same point
112+
quadrantParameters.push(x);
96113
}
114+
}
97115

98-
coordinates.push([x + centerCoords[0], y + centerCoords[1]]);
116+
//NE
117+
parameters.push(0);
118+
for (let i = 0; i < quadrantParameters.length; i++) {
119+
parameters.push(quadrantParameters[i]);
99120
}
100-
coordinates.push(coordinates[0]);
101-
if (units === "degrees") {
102-
return polygon([coordinates], properties);
103-
} else {
104-
return transformRotate(polygon([coordinates], properties), angle, {
105-
pivot,
106-
});
121+
//NW
122+
parameters.push(Math.PI / 2);
123+
for (let i = 0; i < quadrantParameters.length; i++) {
124+
parameters.push(
125+
Math.PI - quadrantParameters[quadrantParameters.length - i - 1]
126+
);
107127
}
108-
}
128+
//SW
129+
parameters.push(Math.PI);
130+
for (let i = 0; i < quadrantParameters.length; i++) {
131+
parameters.push(Math.PI + quadrantParameters[i]);
132+
}
133+
//SE
134+
parameters.push((3 * Math.PI) / 2);
135+
for (let i = 0; i < quadrantParameters.length; i++) {
136+
parameters.push(
137+
2 * Math.PI - quadrantParameters[quadrantParameters.length - i - 1]
138+
);
139+
}
140+
parameters.push(0);
109141

110-
/**
111-
* Get Tan Degrees
112-
*
113-
* @private
114-
* @param {number} deg Degrees
115-
* @returns {number} Tan Degrees
116-
*/
117-
function getTanDeg(deg: number) {
118-
const rad = (deg * Math.PI) / 180;
119-
return Math.tan(rad);
142+
// We can now construct the ellipse
143+
const coords: Position[] = [];
144+
for (const param of parameters) {
145+
const theta = Math.atan2(b * Math.sin(param), a * Math.cos(param));
146+
const r = Math.sqrt(
147+
(Math.pow(a, 2) * Math.pow(b, 2)) /
148+
(Math.pow(a * Math.sin(theta), 2) + Math.pow(b * Math.cos(theta), 2))
149+
);
150+
coords.push(
151+
destination(centerCoords, r, angle + radiansToDegrees(theta), {
152+
units: units,
153+
}).geometry.coordinates
154+
);
155+
}
156+
return polygon([coords], properties);
120157
}
121158

122159
export { ellipse };

packages/turf-ellipse/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@
5353
},
5454
"devDependencies": {
5555
"@placemarkio/check-geojson": "^0.1.12",
56+
"@turf/area": "workspace:^",
5657
"@turf/bbox-polygon": "workspace:^",
5758
"@turf/circle": "workspace:^",
58-
"@turf/destination": "workspace:^",
59+
"@turf/intersect": "workspace:^",
5960
"@turf/truncate": "workspace:^",
6061
"@types/benchmark": "^2.1.5",
6162
"@types/tape": "^4.13.4",
@@ -70,9 +71,10 @@
7071
"write-json-file": "^5.0.0"
7172
},
7273
"dependencies": {
74+
"@turf/destination": "workspace:^",
75+
"@turf/distance": "workspace:^",
7376
"@turf/helpers": "workspace:^",
7477
"@turf/invariant": "workspace:^",
75-
"@turf/rhumb-destination": "workspace:^",
7678
"@turf/transform-rotate": "workspace:^",
7779
"@types/geojson": "^7946.0.10",
7880
"tslib": "^2.8.1"

0 commit comments

Comments
 (0)