667 lines
25 KiB
JavaScript
667 lines
25 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
ArcType,
|
|
ApproximateTerrainHeights,
|
|
Cartesian3,
|
|
Ellipsoid,
|
|
GroundPolylineGeometry,
|
|
Matrix4,
|
|
Math as CesiumMath,
|
|
PolylineGeometry,
|
|
Transforms,
|
|
} from "cesium";
|
|
import {
|
|
alignedGridValues,
|
|
boundedAngularParts,
|
|
fixedGridOrigin,
|
|
geodeticRectangleAreaSquareMeters,
|
|
geodeticToLocalGridPlane,
|
|
graticuleGranularity,
|
|
graticuleLinePlan,
|
|
graticuleMajorTileAt,
|
|
graticuleMajorTileBounds,
|
|
graticuleMajorTileChildren,
|
|
graticuleMajorTileForSector,
|
|
graticuleMajorTileNeighbors,
|
|
graticuleMajorTileSummary,
|
|
graticuleSectorAreaSquareMeters,
|
|
graticuleSectorAt,
|
|
graticuleSectorBounds,
|
|
graticuleSectorNeighbors,
|
|
graticuleSectorSummary,
|
|
isGraticuleMajorLineIndex,
|
|
isGraticuleMajorLineValue,
|
|
isLocalMajorLineIndex,
|
|
localGridPlan,
|
|
localMajorTileAt,
|
|
localMajorTileBounds,
|
|
localMajorTileChildren,
|
|
localMajorTileForSector,
|
|
localMajorTileId,
|
|
localMajorTileNeighbors,
|
|
localMajorTileSummary,
|
|
MAX_GRID_CHILD_PAGE_SIZE,
|
|
MAX_LOCAL_GRID_INDEX,
|
|
localParentSector,
|
|
localSectorAreaSquareMeters,
|
|
localSectorAt,
|
|
localSectorAtGeodetic,
|
|
localSectorBounds,
|
|
localSectorNeighbors,
|
|
localSectorSummary,
|
|
localVolumeAt,
|
|
localVolumeBounds,
|
|
localVolumeId,
|
|
localVolumeNeighbors,
|
|
localVolumeSummary,
|
|
normalizeLongitudeDegrees,
|
|
splitLongitudeRange,
|
|
} from "../apps/catalog/src/mapSectorGrid.mjs";
|
|
|
|
const localDefinition = {
|
|
lod: 1,
|
|
originLatitude: 55.7558,
|
|
originLongitude: 37.6173,
|
|
stepMeters: 1_000,
|
|
};
|
|
|
|
const localHierarchyDefinition = {
|
|
...localDefinition,
|
|
tileSizeMeters: 10_000,
|
|
};
|
|
|
|
const volumeDefinition = {
|
|
...localDefinition,
|
|
altitudeFloorMeters: -50,
|
|
altitudeCeilingMeters: 225,
|
|
altitudeBandMeters: 100,
|
|
};
|
|
|
|
test("fixed origin normalizes WGS84 coordinates without consulting the camera", () => {
|
|
assert.deepEqual(fixedGridOrigin({}), { latitude: 55.7558, longitude: 37.6173 });
|
|
assert.deepEqual(fixedGridOrigin({ gridCenterLatitude: 100, gridCenterLongitude: 540 }), {
|
|
latitude: 89.9,
|
|
longitude: -180,
|
|
});
|
|
assert.equal(normalizeLongitudeDegrees(-540), -180);
|
|
assert.equal(normalizeLongitudeDegrees(360), 0);
|
|
assert.equal(Object.is(normalizeLongitudeDegrees(-360), -0), false);
|
|
});
|
|
|
|
test("local sectors use half-open floor boundaries on both sides of the ENU origin", () => {
|
|
assert.deepEqual(
|
|
[
|
|
[0, 0],
|
|
[999.999, 999.999],
|
|
[1_000, 1_000],
|
|
[-0.001, -0.001],
|
|
[-1_000, -1_000],
|
|
[-1_000.001, -1_000.001],
|
|
].map(([eastMeters, northMeters]) => {
|
|
const sector = localSectorAt({ eastMeters, northMeters }, localDefinition);
|
|
return [sector.eastIndex, sector.northIndex];
|
|
}),
|
|
[
|
|
[0, 0],
|
|
[0, 0],
|
|
[1, 1],
|
|
[-1, -1],
|
|
[-1, -1],
|
|
[-2, -2],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("WGS84 positions use the same tangent-plane address as Cesium sector picking", () => {
|
|
const origin = Cartesian3.fromDegrees(localDefinition.originLongitude, localDefinition.originLatitude, 0);
|
|
const inverseEnu = Matrix4.inverse(Transforms.eastNorthUpToFixedFrame(origin), new Matrix4());
|
|
const point = { longitude: 37.645, latitude: 55.773 };
|
|
const worldPosition = Cartesian3.fromDegrees(point.longitude, point.latitude, 0);
|
|
const surface = Ellipsoid.WGS84.scaleToGeodeticSurface(worldPosition, new Cartesian3());
|
|
const normal = Ellipsoid.WGS84.geodeticSurfaceNormal(surface, new Cartesian3());
|
|
const localSurface = Matrix4.multiplyByPoint(inverseEnu, surface, new Cartesian3());
|
|
const localNormal = Matrix4.multiplyByPointAsVector(inverseEnu, normal, new Cartesian3());
|
|
const normalScale = -localSurface.z / localNormal.z;
|
|
const expected = {
|
|
eastMeters: localSurface.x + localNormal.x * normalScale,
|
|
northMeters: localSurface.y + localNormal.y * normalScale,
|
|
};
|
|
const actual = geodeticToLocalGridPlane(point, localDefinition);
|
|
|
|
assert.ok(Math.abs(actual.eastMeters - expected.eastMeters) < 1e-6);
|
|
assert.ok(Math.abs(actual.northMeters - expected.northMeters) < 1e-6);
|
|
assert.equal(
|
|
localSectorAtGeodetic(point, localDefinition).id,
|
|
localSectorAt(expected, localDefinition).id,
|
|
);
|
|
});
|
|
|
|
test("local sector IDs and bounds stay stable across calls and normalized equivalent origins", () => {
|
|
const first = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition);
|
|
const second = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, {
|
|
...localDefinition,
|
|
originLongitude: localDefinition.originLongitude + 360,
|
|
});
|
|
assert.equal(first.id, "grid/local/55.755800,37.617300/l1/s1000.000/e-1/n+2");
|
|
assert.equal(second.id, first.id);
|
|
assert.deepEqual(localSectorBounds(first, localDefinition.stepMeters), {
|
|
west: -1_000,
|
|
east: 0,
|
|
south: 2_000,
|
|
north: 3_000,
|
|
});
|
|
});
|
|
|
|
test("local sector neighbors and integer-ratio parents preserve signed addressing", () => {
|
|
const child = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition);
|
|
const neighbors = localSectorNeighbors(child, localDefinition);
|
|
assert.deepEqual(
|
|
Object.fromEntries(Object.entries(neighbors).map(([direction, address]) => [
|
|
direction,
|
|
[address.eastIndex, address.northIndex],
|
|
])),
|
|
{
|
|
north: [-1, 3],
|
|
east: [0, 2],
|
|
south: [-1, 1],
|
|
west: [-2, 2],
|
|
},
|
|
);
|
|
const parent = localParentSector(child, 1_000, { ...localDefinition, lod: 2, stepMeters: 5_000 });
|
|
assert.deepEqual([parent.eastIndex, parent.northIndex], [-1, 0]);
|
|
assert.equal(parent.id, "grid/local/55.755800,37.617300/l2/s5000.000/e-1/n+0");
|
|
assert.throws(
|
|
() => localParentSector(child, 1_000, { ...localDefinition, lod: 2, stepMeters: 2_500 }),
|
|
/grid_parent_step_must_be_integer_multiple/,
|
|
);
|
|
});
|
|
|
|
test("local major tiles keep a fixed phase across positive and negative ENU boundaries", () => {
|
|
const samples = [
|
|
[-10_000.001, -2],
|
|
[-10_000, -1],
|
|
[-0.001, -1],
|
|
[0, 0],
|
|
[9_999.999, 0],
|
|
[10_000, 1],
|
|
];
|
|
assert.deepEqual(
|
|
samples.map(([eastMeters]) => localMajorTileAt(
|
|
{ eastMeters, northMeters: 9_999.999 },
|
|
localHierarchyDefinition,
|
|
).eastIndex),
|
|
samples.map(([, expected]) => expected),
|
|
);
|
|
|
|
const tile = localMajorTileAt(
|
|
{ eastMeters: -0.001, northMeters: 9_999.999 },
|
|
localHierarchyDefinition,
|
|
);
|
|
assert.equal(tile.id, "grid/local/55.755800,37.617300/l1/s1000.000/t10000.000/e-1/n+0");
|
|
assert.equal(tile.minorPerSide, 10);
|
|
assert.deepEqual(localMajorTileBounds(tile, localHierarchyDefinition.tileSizeMeters), {
|
|
west: -10_000,
|
|
east: 0,
|
|
south: 0,
|
|
north: 10_000,
|
|
});
|
|
});
|
|
|
|
test("local major parent, children and neighbors are exact inverse hierarchy operations", () => {
|
|
const tile = localMajorTileAt(
|
|
{ eastMeters: -1, northMeters: 1 },
|
|
localHierarchyDefinition,
|
|
);
|
|
const children = localMajorTileChildren(tile, localHierarchyDefinition);
|
|
assert.equal(children.length, 100);
|
|
assert.deepEqual(
|
|
[children[0].eastIndex, children[0].northIndex, children.at(-1).eastIndex, children.at(-1).northIndex],
|
|
[-10, 0, -1, 9],
|
|
);
|
|
for (const child of children) {
|
|
assert.equal(localMajorTileForSector(child, localHierarchyDefinition).id, tile.id);
|
|
}
|
|
const neighbors = localMajorTileNeighbors(tile, localHierarchyDefinition);
|
|
assert.deepEqual(
|
|
Object.fromEntries(Object.entries(neighbors).map(([direction, address]) => [
|
|
direction,
|
|
[address.eastIndex, address.northIndex],
|
|
])),
|
|
{ north: [-1, 1], east: [0, 0], south: [-1, -1], west: [-2, 0] },
|
|
);
|
|
assert.equal(isLocalMajorLineIndex(-10, localHierarchyDefinition), true);
|
|
assert.equal(isLocalMajorLineIndex(-1, localHierarchyDefinition), false);
|
|
assert.equal(isLocalMajorLineIndex(0, localHierarchyDefinition), true);
|
|
});
|
|
|
|
test("large local major tiles require bounded child pages", () => {
|
|
const largeDefinition = { ...localDefinition, stepMeters: 100, tileSizeMeters: 50_000 };
|
|
const tile = localMajorTileAt({ eastMeters: 0, northMeters: 0 }, largeDefinition);
|
|
assert.equal(tile.minorPerSide, 500);
|
|
assert.throws(
|
|
() => localMajorTileChildren(tile, largeDefinition),
|
|
/grid_child_page_limit_exceeded/,
|
|
);
|
|
const tail = localMajorTileChildren(tile, largeDefinition, { offset: 249_998, limit: 2 });
|
|
assert.deepEqual(tail.map(({ eastIndex, northIndex }) => [eastIndex, northIndex]), [
|
|
[498, 499],
|
|
[499, 499],
|
|
]);
|
|
assert.throws(
|
|
() => localMajorTileChildren(tile, largeDefinition, { limit: MAX_GRID_CHILD_PAGE_SIZE + 1 }),
|
|
/grid_child_page_limit_exceeded/,
|
|
);
|
|
});
|
|
|
|
test("local hierarchy rejects non-integral, non-positive and unsafe definitions", () => {
|
|
assert.throws(
|
|
() => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, {
|
|
...localDefinition,
|
|
tileSizeMeters: 2_500,
|
|
}),
|
|
/grid_tile_size_must_be_integer_multiple_of_step/,
|
|
);
|
|
assert.throws(
|
|
() => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, {
|
|
...localDefinition,
|
|
tileSizeMeters: 0,
|
|
}),
|
|
/grid_tile_size_must_be_positive/,
|
|
);
|
|
assert.throws(
|
|
() => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, {
|
|
...localDefinition,
|
|
stepMeters: Number.NaN,
|
|
tileSizeMeters: 10_000,
|
|
}),
|
|
/grid_step_must_be_positive/,
|
|
);
|
|
assert.throws(
|
|
() => localMajorTileId(localHierarchyDefinition, 0.5, 0),
|
|
/grid_index_must_be_safe_integer/,
|
|
);
|
|
assert.doesNotThrow(() => localMajorTileAt(
|
|
{ eastMeters: -0.001, northMeters: 0 },
|
|
{ ...localDefinition, stepMeters: 0.1, tileSizeMeters: 1 },
|
|
));
|
|
});
|
|
|
|
test("local summaries are UI-ready and expose neighbor and major-parent geometry", () => {
|
|
const sector = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition);
|
|
const summary = localSectorSummary(sector, localHierarchyDefinition);
|
|
assert.equal(summary.id, sector.id);
|
|
assert.deepEqual(summary.center, { eastMeters: -500, northMeters: 2_500 });
|
|
assert.equal(summary.areaSquareMeters, 1_000_000);
|
|
assert.equal(localSectorAreaSquareMeters(sector, 1_000), 1_000_000);
|
|
assert.equal(summary.neighbors.west.address.id, localSectorNeighbors(sector, localDefinition).west.id);
|
|
assert.deepEqual(summary.neighbors.north.center, { eastMeters: -500, northMeters: 3_500 });
|
|
assert.equal(summary.majorTile?.id, summary.parentMajorTile?.id);
|
|
assert.equal(summary.majorTile?.minorPerSide, 10);
|
|
assert.deepEqual(summary.majorTile?.center, { eastMeters: -5_000, northMeters: 5_000 });
|
|
assert.equal(summary.majorTile?.areaSquareMeters, 100_000_000);
|
|
|
|
const standalone = localSectorSummary(sector, localDefinition);
|
|
assert.equal(standalone.majorTile, null);
|
|
assert.equal(standalone.parentMajorTile, null);
|
|
const majorSummary = localMajorTileSummary(summary.majorTile.address, localHierarchyDefinition);
|
|
assert.equal(majorSummary.childCount, 100);
|
|
assert.equal(majorSummary.neighbors.east.address.eastIndex, 0);
|
|
});
|
|
|
|
test("local volume addressing is half-open and clips an incomplete last band", () => {
|
|
const samples = [
|
|
[-50, 0],
|
|
[49.999, 0],
|
|
[50, 1],
|
|
[149.999, 1],
|
|
[150, 2],
|
|
[224.999, 2],
|
|
];
|
|
for (const [altitudeMeters, expectedBand] of samples) {
|
|
assert.equal(
|
|
localVolumeAt({ eastMeters: -1, northMeters: 2_500, altitudeMeters }, volumeDefinition)?.bandIndex,
|
|
expectedBand,
|
|
);
|
|
}
|
|
assert.equal(localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: -50.001 }, volumeDefinition), null);
|
|
assert.equal(localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 225 }, volumeDefinition), null);
|
|
|
|
const last = localVolumeAt({ eastMeters: -1, northMeters: 2_500, altitudeMeters: 200 }, volumeDefinition);
|
|
assert.ok(last);
|
|
assert.equal(last.id, "grid/local-volume/55.755800,37.617300/l1/s1000.000/f-50.000/c225.000/h100.000/e-1/n+2/z+2");
|
|
assert.equal(last.altitudeBandMeters, 75);
|
|
assert.deepEqual(localVolumeBounds(last, volumeDefinition), {
|
|
west: -1_000,
|
|
east: 0,
|
|
south: 2_000,
|
|
north: 3_000,
|
|
altitudeFloorMeters: 150,
|
|
altitudeCeilingMeters: 225,
|
|
});
|
|
const summary = localVolumeSummary(last, volumeDefinition);
|
|
assert.equal(summary.address.id, last.id);
|
|
assert.deepEqual(summary.center, {
|
|
eastMeters: -500,
|
|
northMeters: 2_500,
|
|
altitudeMeters: 187.5,
|
|
});
|
|
assert.equal(summary.footprintAreaSquareMeters, 1_000_000);
|
|
assert.equal(summary.volumeCubicMeters, 75_000_000);
|
|
});
|
|
|
|
test("local volume neighbors stop at vertical limits but remain unbounded horizontally", () => {
|
|
const first = localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, volumeDefinition);
|
|
assert.ok(first);
|
|
const firstNeighbors = localVolumeNeighbors(first, volumeDefinition);
|
|
assert.equal(firstNeighbors.below, null);
|
|
assert.equal(firstNeighbors.above?.bandIndex, 1);
|
|
assert.equal(firstNeighbors.west.eastIndex, -1);
|
|
|
|
const last = localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 200 }, volumeDefinition);
|
|
assert.ok(last);
|
|
const lastNeighbors = localVolumeNeighbors(last, volumeDefinition);
|
|
assert.equal(lastNeighbors.above, null);
|
|
assert.equal(lastNeighbors.below?.bandIndex, 1);
|
|
});
|
|
|
|
test("local volume contract rejects degenerate ranges, bands and invalid addresses", () => {
|
|
assert.throws(
|
|
() => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, {
|
|
...volumeDefinition,
|
|
altitudeBandMeters: 0,
|
|
}),
|
|
/grid_altitude_band_must_be_positive/,
|
|
);
|
|
assert.throws(
|
|
() => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, {
|
|
...volumeDefinition,
|
|
altitudeCeilingMeters: -50,
|
|
}),
|
|
/grid_altitude_ceiling_must_exceed_floor/,
|
|
);
|
|
assert.throws(
|
|
() => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, {
|
|
...volumeDefinition,
|
|
altitudeFloorMeters: Number.NaN,
|
|
}),
|
|
/grid_altitude_floor_must_be_finite/,
|
|
);
|
|
assert.equal(
|
|
localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: Number.NaN }, volumeDefinition),
|
|
null,
|
|
);
|
|
assert.throws(
|
|
() => localVolumeId(volumeDefinition, 0, 0, -1),
|
|
/grid_altitude_band_index_out_of_range/,
|
|
);
|
|
assert.throws(
|
|
() => localVolumeId(volumeDefinition, 0, 0, 3),
|
|
/grid_altitude_band_index_out_of_range/,
|
|
);
|
|
});
|
|
|
|
test("local grid plans are symmetric about the immutable origin and cap marker density", () => {
|
|
const plan = localGridPlan({ stepMeters: 1_000, radiusMeters: 2_000, maximumMarkers: 4 });
|
|
assert.deepEqual(plan.lines.map(({ index, offsetMeters }) => [index, offsetMeters]), [
|
|
[-2, -2_000],
|
|
[-1, -1_000],
|
|
[0, 0],
|
|
[1, 1_000],
|
|
[2, 2_000],
|
|
]);
|
|
assert.equal(plan.lines[0].extentMeters, 0);
|
|
assert.equal(plan.lines[2].extentMeters, 2_000);
|
|
assert.equal(plan.markerStride, 2);
|
|
const bounded = localGridPlan({ stepMeters: 100, radiusMeters: 100_000_000 });
|
|
assert.equal(bounded.maximumIndex, MAX_LOCAL_GRID_INDEX);
|
|
assert.equal(bounded.clipped, true);
|
|
assert.ok(bounded.lines.length <= MAX_LOCAL_GRID_INDEX * 2 + 1);
|
|
});
|
|
|
|
test("bounded RHUMB parts compile through Cesium at the equator, poles and date line", () => {
|
|
const compile = (positions) => PolylineGeometry.createGeometry(new PolylineGeometry({
|
|
positions,
|
|
width: 1,
|
|
arcType: ArcType.RHUMB,
|
|
granularity: CesiumMath.toRadians(2),
|
|
}));
|
|
for (const part of boundedAngularParts(-180, 180)) {
|
|
assert.ok(compile([
|
|
Cartesian3.fromDegrees(part.start, 0, 500),
|
|
Cartesian3.fromDegrees(part.end, 0, 500),
|
|
]));
|
|
}
|
|
for (const part of boundedAngularParts(-89.9, 89.9)) {
|
|
assert.ok(compile([
|
|
Cartesian3.fromDegrees(180, part.start, 500),
|
|
Cartesian3.fromDegrees(180, part.end, 500),
|
|
]));
|
|
}
|
|
});
|
|
|
|
test("ground graticule uses metre granularity and compiles without explosive subdivision", () => {
|
|
const stepDegrees = 2;
|
|
const granularity = graticuleGranularity(stepDegrees, true);
|
|
assert.ok(granularity > 200_000 && granularity < 225_000);
|
|
assert.equal(graticuleGranularity(stepDegrees, false), CesiumMath.toRadians(stepDegrees));
|
|
|
|
const positions = boundedAngularParts(-180, 180)
|
|
.reduce((values, part, index) => [
|
|
...values,
|
|
...(index === 0 ? [part.start] : []),
|
|
part.end,
|
|
], [])
|
|
.map((longitude) => Cartesian3.fromDegrees(longitude, 0));
|
|
const previousTerrainHeights = ApproximateTerrainHeights._terrainHeights;
|
|
try {
|
|
ApproximateTerrainHeights._terrainHeights = {};
|
|
const source = new GroundPolylineGeometry({
|
|
positions,
|
|
width: 1,
|
|
arcType: ArcType.RHUMB,
|
|
granularity,
|
|
});
|
|
assert.equal(source.granularity, granularity);
|
|
assert.ok(GroundPolylineGeometry.createGeometry(source));
|
|
} finally {
|
|
ApproximateTerrainHeights._terrainHeights = previousTerrainHeights;
|
|
}
|
|
});
|
|
|
|
test("anti-meridian ranges split explicitly while whole-world ranges keep one interval", () => {
|
|
assert.deepEqual(splitLongitudeRange(170, -170), [
|
|
{ west: 170, east: 180 },
|
|
{ west: -180, east: -170 },
|
|
]);
|
|
assert.deepEqual(splitLongitudeRange(-170, 170), [{ west: -170, east: 170 }]);
|
|
assert.deepEqual(splitLongitudeRange(-180, 180), [{ west: -180, east: 180 }]);
|
|
assert.deepEqual(splitLongitudeRange(10, 370), [{ west: -180, east: 180 }]);
|
|
});
|
|
|
|
test("graticule lines retain a global zero phase and never duplicate the date-line meridian", () => {
|
|
const longitudeIntervals = splitLongitudeRange(170, -170);
|
|
const plan = graticuleLinePlan({
|
|
south: -3.7,
|
|
north: 3.7,
|
|
longitudeIntervals,
|
|
stepDegrees: 2,
|
|
});
|
|
assert.deepEqual(plan.parallels, [-2, 0, 2]);
|
|
assert.deepEqual(plan.meridians.map(({ longitude }) => longitude), [
|
|
170, 172, 174, 176, 178,
|
|
-180, -178, -176, -174, -172, -170,
|
|
]);
|
|
assert.equal(new Set(plan.meridians.map(({ longitude }) => longitude)).size, plan.meridians.length);
|
|
assert.deepEqual(alignedGridValues(1, 7, 2), [2, 4, 6]);
|
|
|
|
const world = graticuleLinePlan({
|
|
south: -2,
|
|
north: 2,
|
|
longitudeIntervals: [{ west: -180, east: 180 }],
|
|
stepDegrees: 2,
|
|
});
|
|
assert.equal(world.meridians.some(({ longitude }) => longitude === 180), false);
|
|
assert.equal(world.meridians.filter(({ longitude }) => longitude === -180).length, 1);
|
|
});
|
|
|
|
test("graticule sector IDs, negative boundaries and bounds use the same global phase", () => {
|
|
const definition = { lod: 4, stepDegrees: 2 };
|
|
const negative = graticuleSectorAt({ longitude: -0.0001, latitude: -0.0001 }, definition);
|
|
const zero = graticuleSectorAt({ longitude: 0, latitude: 0 }, definition);
|
|
const seamWest = graticuleSectorAt({ longitude: -180, latitude: 0 }, definition);
|
|
const seamEast = graticuleSectorAt({ longitude: 180, latitude: 0 }, definition);
|
|
assert.deepEqual([negative.longitudeIndex, negative.latitudeIndex], [-1, -1]);
|
|
assert.equal(negative.id, "grid/wgs84/l4/s2.000000/x-1/y-1");
|
|
assert.equal(zero.id, "grid/wgs84/l4/s2.000000/x+0/y+0");
|
|
assert.deepEqual(graticuleSectorBounds(negative, definition.stepDegrees), {
|
|
west: -2,
|
|
east: 0,
|
|
south: -2,
|
|
north: 0,
|
|
});
|
|
assert.equal(seamEast.id, seamWest.id);
|
|
});
|
|
|
|
test("graticule minor neighbors wrap at the date line and stop at both poles", () => {
|
|
const definition = { lod: 4, stepDegrees: 2 };
|
|
const northEast = graticuleSectorAt({ longitude: 179.999, latitude: 89.999 }, definition);
|
|
const northEastNeighbors = graticuleSectorNeighbors(northEast, definition);
|
|
assert.equal(northEastNeighbors.north, null);
|
|
assert.deepEqual(
|
|
[northEastNeighbors.east.longitudeIndex, northEastNeighbors.east.latitudeIndex],
|
|
[-90, 44],
|
|
);
|
|
assert.deepEqual(
|
|
[northEastNeighbors.west.longitudeIndex, northEastNeighbors.west.latitudeIndex],
|
|
[88, 44],
|
|
);
|
|
|
|
const southWest = graticuleSectorAt({ longitude: -180, latitude: -90 }, definition);
|
|
const southWestNeighbors = graticuleSectorNeighbors(southWest, definition);
|
|
assert.equal(southWestNeighbors.south, null);
|
|
assert.equal(southWestNeighbors.west.longitudeIndex, 89);
|
|
|
|
const unevenDefinition = { lod: 4, stepDegrees: 7 };
|
|
const unevenWest = graticuleSectorAt({ longitude: -180, latitude: 0 }, unevenDefinition);
|
|
const unevenNeighbors = graticuleSectorNeighbors(unevenWest, unevenDefinition);
|
|
assert.equal(unevenNeighbors.west.longitudeIndex, 25);
|
|
assert.throws(
|
|
() => graticuleSectorNeighbors({ ...unevenWest, latitudeIndex: 13 }, unevenDefinition),
|
|
/grid_graticule_latitude_index_out_of_range/,
|
|
);
|
|
});
|
|
|
|
test("graticule major hierarchy has stable IDs, exact children and seam-safe neighbors", () => {
|
|
const definition = { lod: 4, stepDegrees: 2, majorStepDegrees: 10 };
|
|
const tile = graticuleMajorTileAt({ longitude: -0.001, latitude: 9.999 }, definition);
|
|
assert.equal(tile.id, "grid/wgs84/l4/s2.000000/m10.000000/x-1/y+0");
|
|
assert.equal(tile.minorPerSide, 5);
|
|
assert.deepEqual(graticuleMajorTileBounds(tile, definition.majorStepDegrees), {
|
|
west: -10,
|
|
east: 0,
|
|
south: 0,
|
|
north: 10,
|
|
});
|
|
const children = graticuleMajorTileChildren(tile, definition);
|
|
assert.equal(children.length, 25);
|
|
assert.deepEqual(
|
|
[children[0].longitudeIndex, children[0].latitudeIndex, children.at(-1).longitudeIndex, children.at(-1).latitudeIndex],
|
|
[-5, 0, -1, 4],
|
|
);
|
|
for (const child of children) {
|
|
assert.equal(graticuleMajorTileForSector(child, definition).id, tile.id);
|
|
}
|
|
|
|
const seam = graticuleMajorTileAt({ longitude: 179.999, latitude: 89.999 }, definition);
|
|
const seamNeighbors = graticuleMajorTileNeighbors(seam, definition);
|
|
assert.equal(seam.longitudeIndex, 17);
|
|
assert.equal(seam.latitudeIndex, 8);
|
|
assert.equal(seamNeighbors.north, null);
|
|
assert.equal(seamNeighbors.east.longitudeIndex, -18);
|
|
assert.equal(graticuleMajorTileAt({ longitude: 180, latitude: 0 }, definition).longitudeIndex, -18);
|
|
assert.equal(graticuleMajorTileAt({ longitude: -180, latitude: 0 }, definition).id,
|
|
graticuleMajorTileAt({ longitude: 180, latitude: 0 }, definition).id);
|
|
assert.equal(
|
|
graticuleMajorTileAt({ longitude: -180, latitude: 0 }, definition).id,
|
|
graticuleMajorTileAt({ longitude: 540, latitude: 0 }, definition).id,
|
|
);
|
|
assert.equal(isGraticuleMajorLineIndex(-5, definition), true);
|
|
assert.equal(isGraticuleMajorLineIndex(-4, definition), false);
|
|
assert.equal(isGraticuleMajorLineValue(-10, definition), true);
|
|
assert.equal(isGraticuleMajorLineValue(-9.999, definition), false);
|
|
});
|
|
|
|
test("large graticule major tiles expose children through bounded pages", () => {
|
|
const definition = { lod: 4, stepDegrees: 0.1, majorStepDegrees: 90 };
|
|
const tile = graticuleMajorTileAt({ longitude: 0, latitude: 0 }, definition);
|
|
assert.equal(tile.minorPerSide, 900);
|
|
assert.throws(
|
|
() => graticuleMajorTileChildren(tile, definition),
|
|
/grid_child_page_limit_exceeded/,
|
|
);
|
|
const tail = graticuleMajorTileChildren(tile, definition, { offset: 809_999, limit: 1 });
|
|
assert.deepEqual(
|
|
[tail[0].longitudeIndex, tail[0].latitudeIndex],
|
|
[899, 899],
|
|
);
|
|
});
|
|
|
|
test("graticule hierarchy rejects ambiguous global partitions and malformed indices", () => {
|
|
assert.throws(
|
|
() => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, {
|
|
lod: 4,
|
|
stepDegrees: 2,
|
|
majorStepDegrees: 5,
|
|
}),
|
|
/grid_graticule_major_step_must_be_integer_multiple_of_step/,
|
|
);
|
|
assert.throws(
|
|
() => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, {
|
|
lod: 4,
|
|
stepDegrees: 1,
|
|
majorStepDegrees: 7,
|
|
}),
|
|
/grid_graticule_major_step_must_partition_hemisphere/,
|
|
);
|
|
assert.throws(
|
|
() => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, {
|
|
lod: 4,
|
|
stepDegrees: 1,
|
|
majorStepDegrees: 0,
|
|
}),
|
|
/grid_graticule_major_step_must_be_positive/,
|
|
);
|
|
assert.throws(
|
|
() => graticuleMajorTileBounds({ longitudeIndex: 0, latitudeIndex: 9.5 }, 10),
|
|
/grid_index_must_be_safe_integer/,
|
|
);
|
|
});
|
|
|
|
test("WGS84 summaries expose ellipsoidal area, centers, neighbors and major parents", () => {
|
|
const definition = { lod: 4, stepDegrees: 2, majorStepDegrees: 10 };
|
|
const sector = graticuleSectorAt({ longitude: -0.001, latitude: -0.001 }, definition);
|
|
const summary = graticuleSectorSummary(sector, definition);
|
|
assert.deepEqual(summary.center, { longitude: -1, latitude: -1 });
|
|
assert.equal(summary.address.id, sector.id);
|
|
assert.equal(summary.neighbors.east.address.id,
|
|
graticuleSectorAt({ longitude: 0, latitude: -0.001 }, definition).id);
|
|
assert.equal(summary.majorTile?.id, summary.parentMajorTile?.id);
|
|
assert.deepEqual(summary.majorTile?.bounds, { west: -10, east: 0, south: -10, north: 0 });
|
|
assert.equal(summary.majorTile?.childCount, 25);
|
|
assert.ok(summary.areaSquareMeters > 49_000_000_000 && summary.areaSquareMeters < 50_000_000_000);
|
|
|
|
const equator = graticuleSectorAt({ longitude: 0, latitude: 0 }, definition);
|
|
const polar = graticuleSectorAt({ longitude: 0, latitude: 89 }, definition);
|
|
assert.ok(
|
|
graticuleSectorAreaSquareMeters(equator, definition.stepDegrees)
|
|
> graticuleSectorAreaSquareMeters(polar, definition.stepDegrees) * 20,
|
|
);
|
|
const worldArea = geodeticRectangleAreaSquareMeters({ west: -180, east: 180, south: -90, north: 90 });
|
|
assert.ok(worldArea > 5.10e14 && worldArea < 5.11e14);
|
|
|
|
const majorSummary = graticuleMajorTileSummary(summary.majorTile.address, definition);
|
|
assert.equal(majorSummary.neighbors.east.address.longitudeIndex, 0);
|
|
assert.equal(graticuleSectorSummary(sector, { lod: 4, stepDegrees: 2 }).majorTile, null);
|
|
});
|