mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Directions, the Contacts marker and panel, placed annotation labels, the drawn-line measure, the Cockpit and formatRouteDistance each chose the unit and precision on their own, and most chose before rounding: #1450, #1433 and formatDistanceText or kilometreDigits from src/data/distanceFormat.js, which cut after rounding. Each keeps its own metre step and rendering. That also fixes the 10 km cut the Contacts marker and panel, the drawn-line measure and the Cockpit still had (9,960 m read 10.0 km). The helper returns kilometres already rounded, so the plain marker and the localized panel no longer disagree on half-tenths such as 1,450 m. A negative annotation distance now leaves the label without a distance, and a drawn line with a coordinate that is not a number shows no measure, rather than printing 0 m or NaN km. The HUD altitude tag and the spoken view height keep their own unit choice.
396 lines
13 KiB
JavaScript
396 lines
13 KiB
JavaScript
// Pure tests for the manual draw session. Run with: npm test (node --test). No Cesium, no DOM.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createDrawSession,
|
|
addVertex,
|
|
removeLastVertex,
|
|
canFinish,
|
|
finishSpec,
|
|
normalizeShape,
|
|
pathLengthM,
|
|
ringAreaM2,
|
|
ringCentroid,
|
|
formatMeasure,
|
|
drawHint,
|
|
finishReason,
|
|
isFiniteCoordinate,
|
|
closeRing,
|
|
unwrapLongitudes,
|
|
wrapLongitude,
|
|
MAX_VERTICES,
|
|
MIN_VERTICES,
|
|
} from './drawMode.js';
|
|
|
|
test('shapes normalize to area, line or pin, and unknown words fall back to area', () => {
|
|
assert.equal(normalizeShape('route'), 'line');
|
|
assert.equal(normalizeShape('marker'), 'pin');
|
|
assert.equal(normalizeShape('polygon'), 'area');
|
|
assert.equal(normalizeShape(undefined), 'area');
|
|
});
|
|
|
|
test('an area needs three vertices, a line two, a pin one', () => {
|
|
const area = createDrawSession('area');
|
|
addVertex(area, { lon: -97.74, lat: 30.27 });
|
|
addVertex(area, { lon: -97.73, lat: 30.27 });
|
|
assert.equal(canFinish(area), false);
|
|
addVertex(area, { lon: -97.73, lat: 30.28 });
|
|
assert.equal(canFinish(area), true);
|
|
assert.equal(MIN_VERTICES.line, 2);
|
|
assert.equal(MIN_VERTICES.pin, 1);
|
|
});
|
|
|
|
test('a second click within half a metre is the tail of a double-click, not a vertex', () => {
|
|
const line = createDrawSession('line');
|
|
assert.deepEqual(addVertex(line, { lon: 0, lat: 0 }), { added: true });
|
|
assert.deepEqual(addVertex(line, { lon: 0.000001, lat: 0 }), {
|
|
added: false,
|
|
reason: 'duplicate',
|
|
});
|
|
assert.deepEqual(addVertex(line, { lon: 'x', lat: 0 }), {
|
|
added: false,
|
|
reason: 'invalid',
|
|
});
|
|
assert.equal(line.vertices.length, 1);
|
|
});
|
|
|
|
test('a pin keeps exactly one vertex: a later click moves it', () => {
|
|
const pin = createDrawSession('pin');
|
|
addVertex(pin, { lon: 1, lat: 1 });
|
|
addVertex(pin, { lon: 2, lat: 2 });
|
|
assert.equal(pin.vertices.length, 1);
|
|
assert.deepEqual(pin.vertices[0], { lon: 2, lat: 2, height: 0 });
|
|
});
|
|
|
|
test('backspace removes the last vertex and reports whether it did', () => {
|
|
const s = createDrawSession('line');
|
|
assert.equal(removeLastVertex(s), false);
|
|
addVertex(s, { lon: 0, lat: 0 });
|
|
assert.equal(removeLastVertex(s), true);
|
|
assert.equal(s.vertices.length, 0);
|
|
});
|
|
|
|
test('finishSpec yields the engine spec shapes with manual geometry, or null when unfinished', () => {
|
|
const area = createDrawSession('area');
|
|
assert.equal(finishSpec(area), null);
|
|
[
|
|
[-97.74, 30.27],
|
|
[-97.73, 30.27],
|
|
[-97.73, 30.28],
|
|
].forEach(([lon, lat]) => addVertex(area, { lon, lat }));
|
|
assert.deepEqual(finishSpec(area, { label: ' Zilker ', color: 'amber' }), {
|
|
type: 'area',
|
|
manual: true,
|
|
// CLOSED: the last position repeats the first so the outline polyline
|
|
// draws all three sides of the triangle, not two.
|
|
ring: [
|
|
[-97.74, 30.27],
|
|
[-97.73, 30.27],
|
|
[-97.73, 30.28],
|
|
[-97.74, 30.27],
|
|
],
|
|
label: 'Zilker',
|
|
color: 'amber',
|
|
});
|
|
const line = createDrawSession('line');
|
|
addVertex(line, { lon: 0, lat: 0 });
|
|
addVertex(line, { lon: 0.01, lat: 0 });
|
|
assert.deepEqual(finishSpec(line), {
|
|
type: 'route',
|
|
manual: true,
|
|
path: [
|
|
[0, 0],
|
|
[0.01, 0],
|
|
],
|
|
label: null,
|
|
color: 'primary',
|
|
});
|
|
const pin = createDrawSession('pin');
|
|
addVertex(pin, { lon: 151.2, lat: -33.9 });
|
|
assert.deepEqual(finishSpec(pin, { label: 'A shed' }), {
|
|
type: 'pin',
|
|
manual: true,
|
|
latitude: -33.9,
|
|
longitude: 151.2,
|
|
label: 'A shed',
|
|
color: 'primary',
|
|
});
|
|
});
|
|
|
|
test('length and area come out in metres on a local grid', () => {
|
|
const km = pathLengthM([
|
|
{ lon: 0, lat: 0 },
|
|
{ lon: 0, lat: 0.009 },
|
|
]);
|
|
assert.ok(km > 990 && km < 1010, `1 km of latitude, got ${km}`);
|
|
const square = [
|
|
{ lon: 0, lat: 0 },
|
|
{ lon: 0.001, lat: 0 },
|
|
{ lon: 0.001, lat: 0.001 },
|
|
{ lon: 0, lat: 0.001 },
|
|
];
|
|
const m2 = ringAreaM2(square);
|
|
assert.ok(m2 > 12000 && m2 < 12800, `~111 m square, got ${m2}`);
|
|
assert.deepEqual(ringCentroid(square), { lon: 0.0005, lat: 0.0005 });
|
|
});
|
|
|
|
test('the measure and the hint follow the shape and the vertex count', () => {
|
|
const area = createDrawSession('area');
|
|
assert.equal(drawHint(area), 'Click 3 more points.');
|
|
[
|
|
{ lon: 0, lat: 0 },
|
|
{ lon: 0.01, lat: 0 },
|
|
{ lon: 0.01, lat: 0.01 },
|
|
].forEach((v) => addVertex(area, v));
|
|
assert.match(formatMeasure(area), /ha$|km²$|m²$/);
|
|
assert.match(drawHint(area), /double-click or Enter to finish/);
|
|
const pin = createDrawSession('pin');
|
|
assert.equal(drawHint(pin), 'Click where the pin goes.');
|
|
assert.equal(drawHint(null), 'Pick a shape, then click the map.');
|
|
});
|
|
|
|
test('a vertex must be a finite coordinate on the globe', () => {
|
|
assert.equal(isFiniteCoordinate({ lon: 12, lat: -4 }), true);
|
|
assert.equal(isFiniteCoordinate({ lon: 0, lat: 90 }), true);
|
|
for (const bad of [
|
|
null,
|
|
{},
|
|
{ lon: NaN, lat: 0 },
|
|
{ lon: 0, lat: Infinity },
|
|
{ lon: 181, lat: 0 },
|
|
{ lon: 0, lat: -90.5 },
|
|
{ lon: '10', lat: 10 },
|
|
]) {
|
|
assert.equal(isFiniteCoordinate(bad), false, JSON.stringify(bad));
|
|
}
|
|
const line = createDrawSession('line');
|
|
assert.deepEqual(addVertex(line, { lon: 200, lat: 0 }), {
|
|
added: false,
|
|
reason: 'invalid',
|
|
});
|
|
assert.equal(line.vertices.length, 0);
|
|
});
|
|
|
|
test('one shape holds at most MAX_VERTICES points', () => {
|
|
const line = createDrawSession('line');
|
|
for (let i = 0; i < MAX_VERTICES + 25; i += 1)
|
|
addVertex(line, { lon: i * 0.001, lat: 0 });
|
|
assert.equal(line.vertices.length, MAX_VERTICES);
|
|
assert.deepEqual(addVertex(line, { lon: 99, lat: 1 }), {
|
|
added: false,
|
|
reason: 'full',
|
|
});
|
|
assert.match(drawHint(line), /512-point limit reached/);
|
|
|
|
// A pin replaces its one vertex forever: the ceiling cannot strand it.
|
|
const pin = createDrawSession('pin');
|
|
for (let i = 0; i < MAX_VERTICES + 5; i += 1)
|
|
assert.equal(addVertex(pin, { lon: i * 0.01, lat: 0 }).added, true);
|
|
assert.equal(pin.vertices.length, 1);
|
|
});
|
|
|
|
test('a shape that encloses nothing is refused, with a reason', () => {
|
|
// Three points on one meridian are not an area.
|
|
const collinear = createDrawSession('area');
|
|
for (const lat of [0, 0.001, 0.002]) addVertex(collinear, { lon: 0, lat });
|
|
assert.equal(collinear.vertices.length, 3);
|
|
assert.equal(finishReason(collinear), 'degenerate');
|
|
assert.equal(canFinish(collinear), false);
|
|
assert.equal(finishSpec(collinear), null);
|
|
assert.match(drawHint(collinear), /in a line/);
|
|
|
|
// Moving one point off that line makes it a shape again.
|
|
collinear.vertices[2].lon = 0.001;
|
|
assert.equal(finishReason(collinear), 'ok');
|
|
assert.ok(finishSpec(collinear));
|
|
|
|
// A line whose two ends are a handful of centimetres apart has no length.
|
|
const stub = createDrawSession('line');
|
|
addVertex(stub, { lon: 0, lat: 0 });
|
|
addVertex(stub, { lon: 0.000007, lat: 0 }, { minSeparationM: 0.01 });
|
|
assert.equal(stub.vertices.length, 2);
|
|
assert.equal(finishReason(stub), 'degenerate');
|
|
assert.equal(finishSpec(stub), null);
|
|
assert.match(drawHint(stub), /no length/);
|
|
|
|
// And the states in between are named, not lumped together.
|
|
assert.equal(finishReason(createDrawSession('area')), 'too-few');
|
|
assert.equal(finishReason(null), 'invalid');
|
|
});
|
|
|
|
test('a finished area is a CLOSED ring, so its outline has no missing side', () => {
|
|
const area = createDrawSession('area');
|
|
for (const [lon, lat] of [
|
|
[-97.74, 30.27],
|
|
[-97.73, 30.27],
|
|
[-97.73, 30.28],
|
|
])
|
|
addVertex(area, { lon, lat });
|
|
|
|
const spec = finishSpec(area);
|
|
assert.equal(
|
|
spec.ring.length,
|
|
4,
|
|
'three clicked vertices become four ring positions',
|
|
);
|
|
assert.deepEqual(
|
|
spec.ring.at(-1),
|
|
spec.ring[0],
|
|
'the last position repeats the first',
|
|
);
|
|
// The renderer draws the outline as a polyline over exactly these positions:
|
|
// an open ring would draw two of the triangle's three sides.
|
|
assert.equal(spec.ring.length - 1, area.vertices.length);
|
|
|
|
// A line is NOT closed — it is a path, and joining its ends would invent a
|
|
// segment the person never drew.
|
|
const line = createDrawSession('line');
|
|
addVertex(line, { lon: 0, lat: 0 });
|
|
addVertex(line, { lon: 0.01, lat: 0 });
|
|
assert.equal(finishSpec(line).path.length, 2);
|
|
|
|
// closeRing is idempotent: finishing an already-closed ring adds nothing.
|
|
const closed = closeRing([
|
|
[0, 0],
|
|
[1, 0],
|
|
[1, 1],
|
|
[0, 0],
|
|
]);
|
|
assert.equal(closed.length, 4);
|
|
});
|
|
|
|
test('a shape straddling the antimeridian measures what it looks like', () => {
|
|
// 0.002 degrees square at the equator — about 223 m a side, so ~49,600 m2 —
|
|
// sitting across 180. Raw longitude arithmetic reads that 0.002-degree width
|
|
// as 359.998 degrees: the area came out in the thousands of square
|
|
// kilometres and the centroid landed on the Greenwich meridian, half a world
|
|
// from the shape it belongs to.
|
|
const rectangle = [
|
|
{ lon: 179.999, lat: -0.001 },
|
|
{ lon: -179.999, lat: -0.001 },
|
|
{ lon: -179.999, lat: 0.001 },
|
|
{ lon: 179.999, lat: 0.001 },
|
|
];
|
|
const area = ringAreaM2(rectangle);
|
|
assert.ok(
|
|
area > 45_000 && area < 55_000,
|
|
`a ~223 m x ~223 m patch is ~49,600 m2, got ${Math.round(area)} m2`,
|
|
);
|
|
assert.ok(area < 1e6, 'and nowhere near a square kilometre');
|
|
|
|
const centre = ringCentroid(rectangle);
|
|
assert.ok(
|
|
Math.abs(Math.abs(centre.lon) - 180) < 0.01,
|
|
`the centre belongs on the antimeridian, got lon ${centre.lon}`,
|
|
);
|
|
assert.ok(Math.abs(centre.lat) < 0.01, `got lat ${centre.lat}`);
|
|
|
|
// Length was already safe — haversine is periodic in the longitude delta —
|
|
// and this pins that so a "fix" cannot break it.
|
|
const across = pathLengthM([
|
|
{ lon: 179.999, lat: 0 },
|
|
{ lon: -179.999, lat: 0 },
|
|
]);
|
|
assert.ok(
|
|
across > 150 && across < 300,
|
|
`about 222 m, got ${Math.round(across)} m`,
|
|
);
|
|
|
|
// The same shape away from the seam still measures the same.
|
|
const inland = rectangle.map((v) => ({ lon: v.lon - 179.999, lat: v.lat }));
|
|
assert.ok(
|
|
Math.abs(ringAreaM2(inland) - area) < 1,
|
|
'position must not change size',
|
|
);
|
|
|
|
// And the ordinary case is untouched.
|
|
const austin = [
|
|
{ lon: -97.75, lat: 30.26 },
|
|
{ lon: -97.74, lat: 30.26 },
|
|
{ lon: -97.74, lat: 30.27 },
|
|
];
|
|
const centroid = ringCentroid(austin);
|
|
assert.ok(Math.abs(centroid.lon + 97.7433) < 0.001);
|
|
assert.ok(Math.abs(centroid.lat - 30.2633) < 0.001);
|
|
});
|
|
|
|
test('longitude unwrapping is continuous and re-wraps to one canonical range', () => {
|
|
const unwrapped = unwrapLongitudes([
|
|
{ lon: 179.9, lat: 0 },
|
|
{ lon: -179.9, lat: 0 },
|
|
]);
|
|
assert.equal(unwrapped[0].lon, 179.9);
|
|
assert.ok(
|
|
Math.abs(unwrapped[1].lon - 180.1) < 1e-9,
|
|
`continuous neighbour, got ${unwrapped[1].lon}`,
|
|
);
|
|
assert.equal(wrapLongitude(180.1).toFixed(4), (-179.9).toFixed(4));
|
|
assert.equal(wrapLongitude(-190), 170);
|
|
assert.equal(wrapLongitude(10), 10);
|
|
assert.equal(wrapLongitude(180), -180);
|
|
assert.equal(unwrapLongitudes([]).length, 0);
|
|
});
|
|
|
|
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
|
|
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
|
const lat = 999.6 / metresPerDegree;
|
|
const line = createDrawSession('line');
|
|
addVertex(line, { lon: 0, lat: 0 });
|
|
addVertex(line, { lon: 0, lat });
|
|
const metres = pathLengthM(line.vertices);
|
|
assert.ok(
|
|
metres >= 999.5 && metres < 1000,
|
|
`length should round to 1000 m, got ${metres}`,
|
|
);
|
|
assert.equal(formatMeasure(line), '1.0 km');
|
|
assert.match(drawHint(line), /^1\.0 km · /);
|
|
});
|
|
|
|
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
|
|
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
|
const line = createDrawSession('line');
|
|
addVertex(line, { lon: 0, lat: 0 });
|
|
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
|
|
assert.equal(formatMeasure(line), '10 km');
|
|
});
|
|
|
|
test('an area that rounds up to the next unit reads in that unit', () => {
|
|
// A square on the equator whose shoelace area is about `m2`.
|
|
const square = (m2) => {
|
|
const side = Math.sqrt(m2) / 111320;
|
|
const area = createDrawSession('area');
|
|
for (const [lon, lat] of [
|
|
[0, 0],
|
|
[side, 0],
|
|
[side, side],
|
|
[0, side],
|
|
])
|
|
addVertex(area, { lon, lat });
|
|
return area;
|
|
};
|
|
const almostHectare = square(9999.6);
|
|
const metres = ringAreaM2(almostHectare.vertices);
|
|
assert.ok(
|
|
metres >= 9999.5 && metres < 1e4,
|
|
`area should round to 10000 m², got ${metres}`,
|
|
);
|
|
assert.equal(formatMeasure(almostHectare), '1.0 ha');
|
|
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
|
|
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
|
|
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
|
|
const almostKm2 = square(999_960);
|
|
const nearKm2 = ringAreaM2(almostKm2.vertices);
|
|
assert.ok(
|
|
nearKm2 >= 999_500 && nearKm2 < 1e6,
|
|
`area should round to 100.0 ha, got ${nearKm2}`,
|
|
);
|
|
assert.equal(formatMeasure(almostKm2), '1.00 km²');
|
|
const under = square(999_400);
|
|
const underM2 = ringAreaM2(under.vertices);
|
|
assert.ok(
|
|
underM2 >= 998_500 && underM2 < 999_500,
|
|
`area should round to 99.9 ha, got ${underM2}`,
|
|
);
|
|
assert.equal(formatMeasure(under), '99.9 ha');
|
|
});
|