Skip to content

Commit a580530

Browse files
authored
Merge pull request #31 from concord-consortium/NASAEARTH-26-robust-pin-lat-long
NASAEARTH-26 handle more cases of pin lat long values
2 parents 2f86f53 + 6f5cfee commit a580530

File tree

4 files changed

+113
-35
lines changed

4 files changed

+113
-35
lines changed

playwright.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { PlaywrightCoverageOptions } from "@bgotink/playwright-coverage";
2-
import { defineConfig, devices, ReporterDescription } from "@playwright/test";
1+
import type { PlaywrightCoverageOptions } from "@bgotink/playwright-coverage";
2+
import type { ReporterDescription } from "@playwright/test";
3+
import { defineConfig, devices } from "@playwright/test";
34
import path, { dirname } from "path";
45
import { fileURLToPath } from "url";
56

src/models/geo-image.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { latLongToPixel } from "./geo-image";
2+
3+
describe("latLongToPixel", () => {
4+
it("should convert latitude and longitude to pixel coordinates", () => {
5+
expect(latLongToPixel(800, 600, 0, 0))
6+
.toEqual({ x: 400, y: 300 });
7+
expect(latLongToPixel(800, 600, 45, 90))
8+
.toEqual({ x: 600, y: 150 });
9+
expect(latLongToPixel(800, 600, -45, -90))
10+
.toEqual({ x: 200, y: 450 });
11+
});
12+
13+
it("should handle coordinates on the edge of the normal range", () => {
14+
expect(latLongToPixel(800, 600, 90, 180))
15+
.toEqual({ x: 0, y: 0 });
16+
expect(latLongToPixel(800, 600, -90, -180))
17+
.toEqual({ x: 0, y: 599 });
18+
});
19+
20+
it("should clamp latitude values outside the range", () => {
21+
expect(latLongToPixel(800, 600, 100, 0))
22+
.toEqual({ x: 400, y: 0 });
23+
expect(latLongToPixel(800, 600, -100, 0))
24+
.toEqual({ x: 400, y: 599 });
25+
});
26+
27+
it("should normalize longitude values outside the range", () => {
28+
expect(latLongToPixel(800, 600, 0, -160))
29+
.toEqual({ x: 44, y: 300 });
30+
expect(latLongToPixel(800, 600, 0, 200))
31+
.toEqual({ x: 44, y: 300 });
32+
expect(latLongToPixel(800, 600, 0, -520))
33+
.toEqual({ x: 44, y: 300 });
34+
});
35+
});

src/models/geo-image.ts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,45 @@ interface PixelCoordinate {
1919
y: number;
2020
}
2121

22+
/**
23+
* Converts latitude and longitude to pixel coordinates.
24+
* @param imageWidth - The width of the image in pixels.
25+
* @param imageHeight - The height of the image in pixels.
26+
* @param lat - The latitude to convert (-90 to 90), values outside this range will be clamped.
27+
* @param long - The longitude to convert, this will be normalized between -180 to 180.
28+
* @returns The pixel coordinates corresponding to the latitude and longitude.
29+
*/
30+
export function latLongToPixel(
31+
imageWidth: number, imageHeight: number, lat: number, long: number
32+
): PixelCoordinate {
33+
// Validate image dimensions
34+
if (imageWidth <= 0 || imageHeight <= 0) {
35+
throw new Error("Invalid image dimensions");
36+
}
37+
38+
// Shift longitude so -180 is 0 and 180 is 360.
39+
const shiftedLong = long - kLongitudeMin;
40+
// Normalize longitude so it is always in the range [0, 360)
41+
const normalizedLong = ((shiftedLong % kLongitudeRange) + kLongitudeRange) % kLongitudeRange;
42+
43+
// Shift latitude so -90 is 0 and 90 is 180
44+
const shiftedLat = lat - kLatitudeMin;
45+
46+
// Convert to percentages
47+
const xPercent = normalizedLong / kLongitudeRange;
48+
const yPercent = shiftedLat / kLatitudeRange;
49+
50+
// Convert to pixel coordinates
51+
// Note: y is inverted because image coordinates go top-down
52+
// The y value is clamped so it always a valid pixel value
53+
// The x value is normalized above so it will always be a valid pixel value
54+
return {
55+
x: Math.floor(xPercent * imageWidth),
56+
y: Math.min(Math.max(Math.floor((1 - yPercent) * imageHeight), 0), imageHeight - 1),
57+
};
58+
}
59+
60+
2261
/**
2362
* GeoImage represents a single geographic image and provides methods to process it.
2463
* Each instance is tied to a specific image and should be disposed after use.
@@ -81,28 +120,12 @@ export class GeoImage {
81120
throw new Error("Image not loaded");
82121
}
83122

84-
const imageWidth = this.img.naturalWidth;
85-
const imageHeight = this.img.naturalHeight;
86-
87-
// Validate image dimensions
88-
if (imageWidth <= 0 || imageHeight <= 0) {
89-
throw new Error("Invalid image dimensions");
90-
}
91-
92-
// Normalize longitude from -180...180 to 0...360
93-
const normalizedLong = long - kLongitudeMin;
94-
// Normalize latitude from -90...90 to 0...180
95-
const normalizedLat = lat - kLatitudeMin;
96-
// Convert to percentages
97-
const xPercent = normalizedLong / kLongitudeRange;
98-
const yPercent = normalizedLat / kLatitudeRange;
99-
100-
// Convert to pixel coordinates
101-
// Note: y is inverted because image coordinates go top-down
102-
return {
103-
x: Math.floor(xPercent * imageWidth),
104-
y: Math.floor((1 - yPercent) * imageHeight)
105-
};
123+
return latLongToPixel(
124+
this.img.naturalWidth,
125+
this.img.naturalHeight,
126+
lat,
127+
long
128+
);
106129
}
107130

108131
/**

src/models/plugin-state.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ export function pinLabel(pin: IMapPin) {
2828
return `${pin.lat.toFixed(2)}, ${pin.long.toFixed(2)}`;
2929
}
3030

31+
/**
32+
* Return NaN for invalid values, otherwise return the numeric value.
33+
*
34+
* @param value
35+
* @returns
36+
*/
37+
export function numericValue(value: string | number | null | undefined): number {
38+
if (typeof value === "number") {
39+
return value;
40+
}
41+
if (typeof value === "string") {
42+
return parseFloat(value);
43+
}
44+
return NaN;
45+
}
46+
3147
class PluginState {
3248
neoDataset: NeoDataset | undefined;
3349
neoDatasetName = "";
@@ -72,15 +88,18 @@ class PluginState {
7288
const pinResult = yield(getAllItems(kPinDataContextName));
7389
if (pinResult.success) {
7490
const pinData = pinResult.values as any;
75-
this.pins = pinData.map((pin: any) => {
76-
const values = pin.values;
77-
return {
78-
color: values[kPinColorAttributeName],
79-
id: pin.id,
80-
lat: values[kPinLatAttributeName],
81-
long: values[kPinLongAttributeName]
82-
};
83-
});
91+
this.pins = pinData
92+
.map((pin: any) => {
93+
const values = pin.values;
94+
return {
95+
color: values[kPinColorAttributeName],
96+
id: pin.id,
97+
// Handle missing or invalid
98+
lat: numericValue(values[kPinLatAttributeName]),
99+
long: numericValue(values[kPinLongAttributeName])
100+
};
101+
})
102+
.filter((pin: IMapPin) => !isNaN(pin.lat) && !isNaN(pin.long));
84103
}
85104
}
86105

@@ -165,8 +184,8 @@ export async function initializeNeoPlugin() {
165184
const pinCase = (pinValues as any).case;
166185
return {
167186
id: pinCase.id,
168-
lat: pinCase.values.pinLat,
169-
long: pinCase.values.pinLong,
187+
lat: numericValue(pinCase.values.pinLat),
188+
long: numericValue(pinCase.values.pinLong),
170189
color: pinCase.values.pinColor,
171190
};
172191
}

0 commit comments

Comments
 (0)