Files
NODEDC_DESIGN_GUIDELINE/apps/catalog/src/mapSectorGrid.mjs
T

942 lines
38 KiB
JavaScript

const EPSILON = 1e-9;
const WGS84_EQUATORIAL_RADIUS_METERS = 6_378_137;
const WGS84_FLATTENING = 1 / 298.257223563;
const WGS84_ECCENTRICITY_SQUARED = WGS84_FLATTENING * (2 - WGS84_FLATTENING);
const WGS84_ECCENTRICITY = Math.sqrt(WGS84_ECCENTRICITY_SQUARED);
export const MAX_LOCAL_GRID_INDEX = 512;
export const MAX_GRID_CHILD_PAGE_SIZE = 10_000;
const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
const canonicalZero = (value) => Object.is(value, -0) ? 0 : value;
const signedIndex = (value) => value >= 0 ? `+${value}` : String(value);
const decimalToken = (value, digits) => Number(value).toFixed(digits).replace("-0.", "0.");
const positiveNumber = (value, errorCode) => {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) throw new Error(errorCode);
return number;
};
const safeInteger = (value, errorCode = "grid_index_must_be_safe_integer") => {
const number = Number(value);
if (!Number.isSafeInteger(number)) throw new Error(errorCode);
return canonicalZero(number);
};
const integerMultiple = (outer, inner, errorCode) => {
const ratio = outer / inner;
const rounded = Math.round(ratio);
if (!Number.isSafeInteger(rounded)
|| rounded < 1
|| Math.abs(ratio - rounded) > EPSILON * Math.max(1, Math.abs(ratio))) {
throw new Error(errorCode);
}
return rounded;
};
function childPageRange(totalCount, options) {
const total = safeInteger(totalCount, "grid_child_count_must_be_safe_integer");
const offset = safeInteger(options?.offset ?? 0, "grid_child_offset_must_be_safe_integer");
if (offset < 0) throw new Error("grid_child_offset_must_be_non_negative");
const remaining = Math.max(0, total - offset);
const limit = options?.limit == null
? remaining
: safeInteger(options.limit, "grid_child_limit_must_be_safe_integer");
if (limit < 1 && remaining > 0) throw new Error("grid_child_limit_must_be_positive");
if (limit > MAX_GRID_CHILD_PAGE_SIZE) throw new Error("grid_child_page_limit_exceeded");
return { start: Math.min(offset, total), end: Math.min(total, offset + Math.max(0, limit)) };
}
export function normalizeLongitudeDegrees(value) {
const longitude = finite(value, 0);
return canonicalZero(((longitude + 180) % 360 + 360) % 360 - 180);
}
export function fixedGridOrigin(settings) {
return {
latitude: clamp(finite(settings?.gridCenterLatitude, 55.7558), -89.9, 89.9),
longitude: normalizeLongitudeDegrees(finite(settings?.gridCenterLongitude, 37.6173)),
};
}
function localDefinitionToken(definition) {
const latitude = decimalToken(definition.originLatitude, 6);
const longitude = decimalToken(normalizeLongitudeDegrees(definition.originLongitude), 6);
const stepMeters = decimalToken(definition.stepMeters, 3);
return `${latitude},${longitude}/l${definition.lod}/s${stepMeters}`;
}
export function localSectorId(definition, eastIndex, northIndex) {
return `grid/local/${localDefinitionToken(definition)}/e${signedIndex(eastIndex)}/n${signedIndex(northIndex)}`;
}
export function localSectorAt(point, definition) {
const stepMeters = Math.max(EPSILON, finite(definition.stepMeters, 1));
const eastIndex = canonicalZero(Math.floor(finite(point.eastMeters, 0) / stepMeters));
const northIndex = canonicalZero(Math.floor(finite(point.northMeters, 0) / stepMeters));
return {
family: "local-enu",
lod: definition.lod,
eastIndex,
northIndex,
id: localSectorId({ ...definition, stepMeters }, eastIndex, northIndex),
};
}
export function localSectorBounds(address, stepMeters) {
const step = Math.max(EPSILON, finite(stepMeters, 1));
return {
west: address.eastIndex * step,
east: (address.eastIndex + 1) * step,
south: address.northIndex * step,
north: (address.northIndex + 1) * step,
};
}
export function localSectorNeighbors(address, definition) {
const at = (eastIndex, northIndex) => ({
family: "local-enu",
lod: definition.lod,
eastIndex,
northIndex,
id: localSectorId(definition, eastIndex, northIndex),
});
return {
north: at(address.eastIndex, address.northIndex + 1),
east: at(address.eastIndex + 1, address.northIndex),
south: at(address.eastIndex, address.northIndex - 1),
west: at(address.eastIndex - 1, address.northIndex),
};
}
export function localParentSector(address, childStepMeters, parentDefinition) {
const childStep = Math.max(EPSILON, finite(childStepMeters, 1));
const parentStep = Math.max(EPSILON, finite(parentDefinition.stepMeters, 1));
const ratio = parentStep / childStep;
if (!Number.isInteger(ratio) || ratio < 1) throw new Error("grid_parent_step_must_be_integer_multiple");
return localSectorAt({
eastMeters: address.eastIndex * childStep,
northMeters: address.northIndex * childStep,
}, parentDefinition);
}
function localHierarchyMetrics(definition) {
const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive");
const tileSizeMeters = positiveNumber(definition?.tileSizeMeters, "grid_tile_size_must_be_positive");
const minorPerMajor = integerMultiple(
tileSizeMeters,
stepMeters,
"grid_tile_size_must_be_integer_multiple_of_step",
);
return { stepMeters, tileSizeMeters, minorPerMajor };
}
function localMajorDefinitionToken(definition, metrics = localHierarchyMetrics(definition)) {
return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/t${decimalToken(metrics.tileSizeMeters, 3)}`;
}
export function localMajorTileId(definition, eastIndex, northIndex) {
const metrics = localHierarchyMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
return `grid/local/${localMajorDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}`;
}
function localMajorAddress(definition, eastIndex, northIndex) {
const metrics = localHierarchyMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
return {
family: "local-enu-major",
lod: definition.lod,
eastIndex: east,
northIndex: north,
minorPerSide: metrics.minorPerMajor,
id: localMajorTileId(definition, east, north),
};
}
export function localMajorTileAt(point, definition) {
const { tileSizeMeters } = localHierarchyMetrics(definition);
return localMajorAddress(
definition,
Math.floor(finite(point?.eastMeters, 0) / tileSizeMeters),
Math.floor(finite(point?.northMeters, 0) / tileSizeMeters),
);
}
export function localMajorTileBounds(address, tileSizeMeters) {
const size = positiveNumber(tileSizeMeters, "grid_tile_size_must_be_positive");
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
return {
west: eastIndex * size,
east: (eastIndex + 1) * size,
south: northIndex * size,
north: (northIndex + 1) * size,
};
}
export function localMajorTileForSector(address, definition) {
const { minorPerMajor } = localHierarchyMetrics(definition);
return localMajorAddress(
definition,
Math.floor(safeInteger(address?.eastIndex) / minorPerMajor),
Math.floor(safeInteger(address?.northIndex) / minorPerMajor),
);
}
export function localMajorTileChildren(address, definition, options) {
const { minorPerMajor } = localHierarchyMetrics(definition);
const majorEastIndex = safeInteger(address?.eastIndex);
const majorNorthIndex = safeInteger(address?.northIndex);
const firstEastIndex = safeInteger(majorEastIndex * minorPerMajor);
const firstNorthIndex = safeInteger(majorNorthIndex * minorPerMajor);
safeInteger(firstEastIndex + minorPerMajor - 1);
safeInteger(firstNorthIndex + minorPerMajor - 1);
const page = childPageRange(minorPerMajor ** 2, options);
const children = [];
for (let childIndex = page.start; childIndex < page.end; childIndex += 1) {
const eastIndex = firstEastIndex + childIndex % minorPerMajor;
const northIndex = firstNorthIndex + Math.floor(childIndex / minorPerMajor);
children.push({
family: "local-enu",
lod: definition.lod,
eastIndex,
northIndex,
id: localSectorId(definition, eastIndex, northIndex),
});
}
return children;
}
export function localMajorTileNeighbors(address, definition) {
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
return {
north: localMajorAddress(definition, eastIndex, northIndex + 1),
east: localMajorAddress(definition, eastIndex + 1, northIndex),
south: localMajorAddress(definition, eastIndex, northIndex - 1),
west: localMajorAddress(definition, eastIndex - 1, northIndex),
};
}
export function isLocalMajorLineIndex(lineIndex, definition) {
const { minorPerMajor } = localHierarchyMetrics(definition);
return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0;
}
export function localSectorCenter(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return {
eastMeters: (bounds.west + bounds.east) / 2,
northMeters: (bounds.south + bounds.north) / 2,
};
}
export function localSectorAreaSquareMeters(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return (bounds.east - bounds.west) * (bounds.north - bounds.south);
}
function localSectorDetail(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return {
address,
bounds,
center: localSectorCenter(address, stepMeters),
areaSquareMeters: localSectorAreaSquareMeters(address, stepMeters),
};
}
export function localSectorSummary(address, definition) {
const bounds = localSectorBounds(address, definition.stepMeters);
const neighbors = Object.fromEntries(
Object.entries(localSectorNeighbors(address, definition))
.map(([direction, neighbor]) => [direction, localSectorDetail(neighbor, definition.stepMeters)]),
);
const majorTile = definition?.tileSizeMeters == null
? null
: localMajorTileSummary(localMajorTileForSector(address, definition), definition);
return {
id: address.id,
address,
family: "local-enu",
lod: address.lod,
label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`,
indices: { eastIndex: address.eastIndex, northIndex: address.northIndex },
bounds,
center: localSectorCenter(address, definition.stepMeters),
areaSquareMeters: localSectorAreaSquareMeters(address, definition.stepMeters),
neighbors,
majorTile,
parentMajorTile: majorTile,
};
}
export function localMajorTileCenter(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return {
eastMeters: (bounds.west + bounds.east) / 2,
northMeters: (bounds.south + bounds.north) / 2,
};
}
export function localMajorTileAreaSquareMeters(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return (bounds.east - bounds.west) * (bounds.north - bounds.south);
}
function localMajorTileDetail(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return {
address,
bounds,
center: localMajorTileCenter(address, tileSizeMeters),
areaSquareMeters: localMajorTileAreaSquareMeters(address, tileSizeMeters),
};
}
export function localMajorTileSummary(address, definition) {
const metrics = localHierarchyMetrics(definition);
const bounds = localMajorTileBounds(address, metrics.tileSizeMeters);
const neighbors = Object.fromEntries(
Object.entries(localMajorTileNeighbors(address, definition))
.map(([direction, neighbor]) => [direction, localMajorTileDetail(neighbor, metrics.tileSizeMeters)]),
);
return {
id: address.id,
address,
family: "local-enu-major",
lod: address.lod,
label: `L${address.lod} TILE E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`,
indices: { eastIndex: address.eastIndex, northIndex: address.northIndex },
minorPerSide: metrics.minorPerMajor,
childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"),
bounds,
center: localMajorTileCenter(address, metrics.tileSizeMeters),
areaSquareMeters: localMajorTileAreaSquareMeters(address, metrics.tileSizeMeters),
neighbors,
};
}
function localVolumeMetrics(definition) {
const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive");
const altitudeBandMeters = positiveNumber(
definition?.altitudeBandMeters,
"grid_altitude_band_must_be_positive",
);
const altitudeFloorMeters = Number(
definition?.altitudeFloorMeters ?? definition?.altitudeOriginMeters ?? 0,
);
if (!Number.isFinite(altitudeFloorMeters)) {
throw new Error("grid_altitude_floor_must_be_finite");
}
const altitudeCeilingMeters = definition?.altitudeCeilingMeters == null
? Number.POSITIVE_INFINITY
: Number(definition.altitudeCeilingMeters);
if ((!Number.isFinite(altitudeCeilingMeters) && altitudeCeilingMeters !== Number.POSITIVE_INFINITY)
|| altitudeCeilingMeters <= altitudeFloorMeters) {
throw new Error("grid_altitude_ceiling_must_exceed_floor");
}
return { stepMeters, altitudeBandMeters, altitudeFloorMeters, altitudeCeilingMeters };
}
function localVolumeDefinitionToken(definition, metrics = localVolumeMetrics(definition)) {
const ceilingToken = Number.isFinite(metrics.altitudeCeilingMeters)
? decimalToken(metrics.altitudeCeilingMeters, 3)
: "inf";
return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/f${decimalToken(metrics.altitudeFloorMeters, 3)}/c${ceilingToken}/h${decimalToken(metrics.altitudeBandMeters, 3)}`;
}
export function localVolumeId(definition, eastIndex, northIndex, bandIndex) {
const metrics = localVolumeMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (band < 0
|| metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
return `grid/local-volume/${localVolumeDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}/z${signedIndex(band)}`;
}
function localVolumeAddress(definition, eastIndex, northIndex, bandIndex) {
const metrics = localVolumeMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (band < 0) throw new Error("grid_altitude_band_index_out_of_range");
const altitudeFloorMeters = metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters;
if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
const altitudeCeilingMeters = Math.min(
metrics.altitudeCeilingMeters,
altitudeFloorMeters + metrics.altitudeBandMeters,
);
return {
family: "local-enu-volume",
lod: definition.lod,
eastIndex: east,
northIndex: north,
bandIndex: band,
altitudeFloorMeters,
altitudeCeilingMeters,
altitudeBandMeters: altitudeCeilingMeters - altitudeFloorMeters,
id: localVolumeId(definition, east, north, band),
};
}
export function localVolumeAt(point, definition) {
const metrics = localVolumeMetrics(definition);
const altitudeMeters = Number(point?.altitudeMeters);
if (!Number.isFinite(altitudeMeters)
|| altitudeMeters < metrics.altitudeFloorMeters
|| altitudeMeters >= metrics.altitudeCeilingMeters) {
return null;
}
const eastIndex = Math.floor(finite(point?.eastMeters, 0) / metrics.stepMeters);
const northIndex = Math.floor(finite(point?.northMeters, 0) / metrics.stepMeters);
const bandIndex = Math.floor(
(altitudeMeters - metrics.altitudeFloorMeters) / metrics.altitudeBandMeters,
);
return localVolumeAddress(definition, eastIndex, northIndex, bandIndex);
}
export function localVolumeBounds(address, definition) {
const metrics = localVolumeMetrics(definition);
const horizontal = localSectorBounds(address, metrics.stepMeters);
const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (bandIndex < 0) throw new Error("grid_altitude_band_index_out_of_range");
const altitudeFloorMeters = metrics.altitudeFloorMeters + bandIndex * metrics.altitudeBandMeters;
if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
return {
...horizontal,
altitudeFloorMeters,
altitudeCeilingMeters: Math.min(
metrics.altitudeCeilingMeters,
altitudeFloorMeters + metrics.altitudeBandMeters,
),
};
}
export function localVolumeNeighbors(address, definition) {
const metrics = localVolumeMetrics(definition);
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer");
const aboveFloor = metrics.altitudeFloorMeters + (bandIndex + 1) * metrics.altitudeBandMeters;
return {
north: localVolumeAddress(definition, eastIndex, northIndex + 1, bandIndex),
east: localVolumeAddress(definition, eastIndex + 1, northIndex, bandIndex),
south: localVolumeAddress(definition, eastIndex, northIndex - 1, bandIndex),
west: localVolumeAddress(definition, eastIndex - 1, northIndex, bandIndex),
above: aboveFloor >= metrics.altitudeCeilingMeters
? null
: localVolumeAddress(definition, eastIndex, northIndex, bandIndex + 1),
below: bandIndex === 0
? null
: localVolumeAddress(definition, eastIndex, northIndex, bandIndex - 1),
};
}
export function localVolumeCenter(address, definition) {
const horizontal = localSectorCenter(address, definition.stepMeters);
const bounds = localVolumeBounds(address, definition);
return {
...horizontal,
altitudeMeters: (bounds.altitudeFloorMeters + bounds.altitudeCeilingMeters) / 2,
};
}
export function localVolumeSummary(address, definition) {
const bounds = localVolumeBounds(address, definition);
const footprintAreaSquareMeters = (bounds.east - bounds.west) * (bounds.north - bounds.south);
return {
id: address.id,
address,
family: "local-enu-volume",
lod: address.lod,
label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)} Z${signedIndex(address.bandIndex)}`,
indices: {
eastIndex: address.eastIndex,
northIndex: address.northIndex,
bandIndex: address.bandIndex,
},
bounds,
center: localVolumeCenter(address, definition),
footprintAreaSquareMeters,
volumeCubicMeters: footprintAreaSquareMeters
* (bounds.altitudeCeilingMeters - bounds.altitudeFloorMeters),
};
}
export function localGridPlan({ stepMeters, radiusMeters, maximumMarkers = 5_000 }) {
const step = Math.max(1, finite(stepMeters, 1));
const requestedRadius = Math.max(step, finite(radiusMeters, step));
// Invalid in-memory inspector edits must not allocate millions of lines
// before server validation can reject them. Canonical layouts satisfy the
// same ratio explicitly; this is the renderer's final fail-safe.
const maximumIndex = Math.min(MAX_LOCAL_GRID_INDEX, Math.ceil(requestedRadius / step));
const radius = Math.min(requestedRadius, maximumIndex * step);
const radiusSquared = radius * radius;
const lines = [];
for (let index = -maximumIndex; index <= maximumIndex; index += 1) {
const offsetMeters = index * step;
if (Math.abs(offsetMeters) > radius + EPSILON) continue;
lines.push({
index,
offsetMeters,
extentMeters: Math.sqrt(Math.max(0, radiusSquared - offsetMeters * offsetMeters)),
});
}
const approximateMarkers = Math.PI * (radius / step) ** 2;
const markerStride = Math.max(1, Math.ceil(Math.sqrt(approximateMarkers / Math.max(1, maximumMarkers))));
return { stepMeters: step, radiusMeters: radius, requestedRadiusMeters: requestedRadius, clipped: radius < requestedRadius, maximumIndex, markerStride, lines };
}
function graticuleDefinitionToken(definition) {
return `wgs84/l${definition.lod}/s${decimalToken(definition.stepDegrees, 6)}`;
}
export function graticuleSectorId(definition, longitudeIndex, latitudeIndex) {
return `grid/${graticuleDefinitionToken(definition)}/x${signedIndex(longitudeIndex)}/y${signedIndex(latitudeIndex)}`;
}
export function graticuleSectorAt(point, definition) {
const stepDegrees = Math.max(EPSILON, finite(definition.stepDegrees, 1));
const longitude = normalizeLongitudeDegrees(point.longitude);
const latitude = clamp(finite(point.latitude, 0), -90, 90 - EPSILON);
const longitudeIndex = canonicalZero(Math.floor(longitude / stepDegrees));
const latitudeIndex = canonicalZero(Math.floor(latitude / stepDegrees));
return {
family: "wgs84-graticule",
lod: definition.lod,
longitudeIndex,
latitudeIndex,
id: graticuleSectorId({ ...definition, stepDegrees }, longitudeIndex, latitudeIndex),
};
}
export function graticuleSectorBounds(address, stepDegrees) {
const step = Math.max(EPSILON, finite(stepDegrees, 1));
return {
west: Math.max(-180, address.longitudeIndex * step),
east: Math.min(180, (address.longitudeIndex + 1) * step),
south: Math.max(-90, address.latitudeIndex * step),
north: Math.min(90, (address.latitudeIndex + 1) * step),
};
}
function graticuleTopology(stepDegrees, errorCode = "grid_graticule_step_must_partition_hemisphere") {
const step = positiveNumber(stepDegrees, "grid_graticule_step_must_be_positive");
const longitudeHemisphereCount = integerMultiple(180, step, errorCode);
const latitudeHemisphereCount = integerMultiple(90, step, errorCode);
return {
stepDegrees: step,
longitudeHemisphereCount,
latitudeHemisphereCount,
longitudeCount: longitudeHemisphereCount * 2,
latitudeCount: latitudeHemisphereCount * 2,
};
}
function wrapGraticuleIndex(index, hemisphereCount) {
const span = hemisphereCount * 2;
return canonicalZero(((index + hemisphereCount) % span + span) % span - hemisphereCount);
}
function graticuleSectorAddress(definition, longitudeIndex, latitudeIndex) {
const longitude = safeInteger(longitudeIndex);
const latitude = safeInteger(latitudeIndex);
return {
family: "wgs84-graticule",
lod: definition.lod,
longitudeIndex: longitude,
latitudeIndex: latitude,
id: graticuleSectorId(definition, longitude, latitude),
};
}
export function graticuleSectorNeighbors(address, definition) {
const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive");
const minimumLongitudeIndex = Math.floor(-180 / stepDegrees);
const maximumLongitudeIndex = Math.floor((180 - EPSILON) / stepDegrees);
const minimumLatitudeIndex = Math.floor(-90 / stepDegrees);
const maximumLatitudeIndex = Math.floor((90 - EPSILON) / stepDegrees);
const longitudeIndex = safeInteger(address?.longitudeIndex);
const latitudeIndex = safeInteger(address?.latitudeIndex);
if (longitudeIndex < minimumLongitudeIndex || longitudeIndex > maximumLongitudeIndex) {
throw new Error("grid_graticule_longitude_index_out_of_range");
}
if (latitudeIndex < minimumLatitudeIndex || latitudeIndex > maximumLatitudeIndex) {
throw new Error("grid_graticule_latitude_index_out_of_range");
}
return {
north: latitudeIndex === maximumLatitudeIndex
? null
: graticuleSectorAddress(definition, longitudeIndex, latitudeIndex + 1),
east: graticuleSectorAddress(
definition,
longitudeIndex === maximumLongitudeIndex ? minimumLongitudeIndex : longitudeIndex + 1,
latitudeIndex,
),
south: latitudeIndex === minimumLatitudeIndex
? null
: graticuleSectorAddress(definition, longitudeIndex, latitudeIndex - 1),
west: graticuleSectorAddress(
definition,
longitudeIndex === minimumLongitudeIndex ? maximumLongitudeIndex : longitudeIndex - 1,
latitudeIndex,
),
};
}
function graticuleHierarchyMetrics(definition) {
const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive");
const majorStepDegrees = positiveNumber(
definition?.majorStepDegrees,
"grid_graticule_major_step_must_be_positive",
);
const minorPerMajor = integerMultiple(
majorStepDegrees,
stepDegrees,
"grid_graticule_major_step_must_be_integer_multiple_of_step",
);
const topology = graticuleTopology(
majorStepDegrees,
"grid_graticule_major_step_must_partition_hemisphere",
);
return { ...topology, stepDegrees, majorStepDegrees, minorPerMajor };
}
function graticuleMajorDefinitionToken(definition, metrics = graticuleHierarchyMetrics(definition)) {
return `${graticuleDefinitionToken({ ...definition, stepDegrees: metrics.stepDegrees })}/m${decimalToken(metrics.majorStepDegrees, 6)}`;
}
export function graticuleMajorTileId(definition, longitudeIndex, latitudeIndex) {
const metrics = graticuleHierarchyMetrics(definition);
const longitude = wrapGraticuleIndex(
safeInteger(longitudeIndex),
metrics.longitudeHemisphereCount,
);
const latitude = safeInteger(latitudeIndex);
if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return `grid/${graticuleMajorDefinitionToken(definition, metrics)}/x${signedIndex(longitude)}/y${signedIndex(latitude)}`;
}
function graticuleMajorAddress(definition, longitudeIndex, latitudeIndex) {
const metrics = graticuleHierarchyMetrics(definition);
const longitude = wrapGraticuleIndex(safeInteger(longitudeIndex), metrics.longitudeHemisphereCount);
const latitude = safeInteger(latitudeIndex);
if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return {
family: "wgs84-graticule-major",
lod: definition.lod,
longitudeIndex: longitude,
latitudeIndex: latitude,
minorPerSide: metrics.minorPerMajor,
id: graticuleMajorTileId(definition, longitude, latitude),
};
}
export function graticuleMajorTileAt(point, definition) {
const { majorStepDegrees } = graticuleHierarchyMetrics(definition);
const longitude = normalizeLongitudeDegrees(point?.longitude);
const latitude = clamp(finite(point?.latitude, 0), -90, 90 - EPSILON);
return graticuleMajorAddress(
definition,
Math.floor(longitude / majorStepDegrees),
Math.floor(latitude / majorStepDegrees),
);
}
export function graticuleMajorTileBounds(address, majorStepDegrees) {
const topology = graticuleTopology(
majorStepDegrees,
"grid_graticule_major_step_must_partition_hemisphere",
);
const longitudeIndex = wrapGraticuleIndex(
safeInteger(address?.longitudeIndex),
topology.longitudeHemisphereCount,
);
const latitudeIndex = safeInteger(address?.latitudeIndex);
if (latitudeIndex < -topology.latitudeHemisphereCount
|| latitudeIndex >= topology.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return {
west: longitudeIndex * topology.stepDegrees,
east: (longitudeIndex + 1) * topology.stepDegrees,
south: latitudeIndex * topology.stepDegrees,
north: (latitudeIndex + 1) * topology.stepDegrees,
};
}
export function graticuleMajorTileForSector(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
return graticuleMajorAddress(
definition,
Math.floor(safeInteger(address?.longitudeIndex) / metrics.minorPerMajor),
Math.floor(safeInteger(address?.latitudeIndex) / metrics.minorPerMajor),
);
}
export function graticuleMajorTileChildren(address, definition, options) {
const metrics = graticuleHierarchyMetrics(definition);
const major = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex);
const firstLongitudeIndex = major.longitudeIndex * metrics.minorPerMajor;
const firstLatitudeIndex = major.latitudeIndex * metrics.minorPerMajor;
const page = childPageRange(metrics.minorPerMajor ** 2, options);
const children = [];
for (let childIndex = page.start; childIndex < page.end; childIndex += 1) {
children.push(graticuleSectorAddress(
definition,
firstLongitudeIndex + childIndex % metrics.minorPerMajor,
firstLatitudeIndex + Math.floor(childIndex / metrics.minorPerMajor),
));
}
return children;
}
export function graticuleMajorTileNeighbors(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
const tile = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex);
return {
north: tile.latitudeIndex === metrics.latitudeHemisphereCount - 1
? null
: graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex + 1),
east: graticuleMajorAddress(definition, tile.longitudeIndex + 1, tile.latitudeIndex),
south: tile.latitudeIndex === -metrics.latitudeHemisphereCount
? null
: graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex - 1),
west: graticuleMajorAddress(definition, tile.longitudeIndex - 1, tile.latitudeIndex),
};
}
export function isGraticuleMajorLineIndex(lineIndex, definition) {
const { minorPerMajor } = graticuleHierarchyMetrics(definition);
return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0;
}
export function isGraticuleMajorLineValue(valueDegrees, definition) {
const { majorStepDegrees } = graticuleHierarchyMetrics(definition);
const ratio = finite(valueDegrees, Number.NaN) / majorStepDegrees;
return Number.isFinite(ratio)
&& Math.abs(ratio - Math.round(ratio)) <= EPSILON * Math.max(1, Math.abs(ratio));
}
function authalicQ(latitudeRadians) {
const sine = Math.sin(latitudeRadians);
const eccentricitySine = WGS84_ECCENTRICITY * sine;
return (1 - WGS84_ECCENTRICITY_SQUARED) * (
sine / (1 - WGS84_ECCENTRICITY_SQUARED * sine * sine)
- Math.log((1 - eccentricitySine) / (1 + eccentricitySine)) / (2 * WGS84_ECCENTRICITY)
);
}
export function geodeticRectangleAreaSquareMeters(bounds) {
const south = clamp(finite(bounds?.south, -90), -90, 90);
const north = clamp(finite(bounds?.north, 90), -90, 90);
if (north <= south) return 0;
const rawWest = finite(bounds?.west, -180);
const rawEast = finite(bounds?.east, 180);
let longitudeSpanDegrees = rawEast - rawWest;
if (Math.abs(longitudeSpanDegrees) >= 360 - EPSILON) longitudeSpanDegrees = 360;
else if (longitudeSpanDegrees < 0) longitudeSpanDegrees += 360;
longitudeSpanDegrees = clamp(longitudeSpanDegrees, 0, 360);
const longitudeSpanRadians = longitudeSpanDegrees * Math.PI / 180;
const southRadians = south * Math.PI / 180;
const northRadians = north * Math.PI / 180;
return WGS84_EQUATORIAL_RADIUS_METERS ** 2
* longitudeSpanRadians
* Math.abs(authalicQ(northRadians) - authalicQ(southRadians))
/ 2;
}
function geodeticBoundsCenter(bounds) {
const width = bounds.east - bounds.west;
return {
longitude: width >= 360 - EPSILON
? 0
: normalizeLongitudeDegrees(bounds.west + width / 2),
latitude: (bounds.south + bounds.north) / 2,
};
}
export function graticuleSectorCenter(address, stepDegrees) {
return geodeticBoundsCenter(graticuleSectorBounds(address, stepDegrees));
}
export function graticuleSectorAreaSquareMeters(address, stepDegrees) {
return geodeticRectangleAreaSquareMeters(graticuleSectorBounds(address, stepDegrees));
}
function graticuleSectorDetail(address, stepDegrees) {
const bounds = graticuleSectorBounds(address, stepDegrees);
return {
address,
bounds,
center: graticuleSectorCenter(address, stepDegrees),
areaSquareMeters: graticuleSectorAreaSquareMeters(address, stepDegrees),
};
}
export function graticuleSectorSummary(address, definition) {
const bounds = graticuleSectorBounds(address, definition.stepDegrees);
const neighbors = Object.fromEntries(
Object.entries(graticuleSectorNeighbors(address, definition))
.map(([direction, neighbor]) => [
direction,
neighbor == null ? null : graticuleSectorDetail(neighbor, definition.stepDegrees),
]),
);
const majorTile = definition?.majorStepDegrees == null
? null
: graticuleMajorTileSummary(graticuleMajorTileForSector(address, definition), definition);
return {
id: address.id,
address,
family: "wgs84-graticule",
lod: address.lod,
label: `L${address.lod} X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`,
indices: {
longitudeIndex: address.longitudeIndex,
latitudeIndex: address.latitudeIndex,
},
bounds,
center: graticuleSectorCenter(address, definition.stepDegrees),
areaSquareMeters: graticuleSectorAreaSquareMeters(address, definition.stepDegrees),
neighbors,
majorTile,
parentMajorTile: majorTile,
};
}
export function graticuleMajorTileCenter(address, majorStepDegrees) {
return geodeticBoundsCenter(graticuleMajorTileBounds(address, majorStepDegrees));
}
export function graticuleMajorTileAreaSquareMeters(address, majorStepDegrees) {
return geodeticRectangleAreaSquareMeters(graticuleMajorTileBounds(address, majorStepDegrees));
}
function graticuleMajorTileDetail(address, majorStepDegrees) {
const bounds = graticuleMajorTileBounds(address, majorStepDegrees);
return {
address,
bounds,
center: graticuleMajorTileCenter(address, majorStepDegrees),
areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, majorStepDegrees),
};
}
export function graticuleMajorTileSummary(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
const bounds = graticuleMajorTileBounds(address, metrics.majorStepDegrees);
const neighbors = Object.fromEntries(
Object.entries(graticuleMajorTileNeighbors(address, definition))
.map(([direction, neighbor]) => [
direction,
neighbor == null ? null : graticuleMajorTileDetail(neighbor, metrics.majorStepDegrees),
]),
);
return {
id: address.id,
address,
family: "wgs84-graticule-major",
lod: address.lod,
label: `L${address.lod} TILE X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`,
indices: {
longitudeIndex: address.longitudeIndex,
latitudeIndex: address.latitudeIndex,
},
minorPerSide: metrics.minorPerMajor,
childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"),
bounds,
center: graticuleMajorTileCenter(address, metrics.majorStepDegrees),
areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, metrics.majorStepDegrees),
neighbors,
};
}
export function splitLongitudeRange(west, east) {
const rawWest = finite(west, -180);
const rawEast = finite(east, 180);
if (Math.abs(rawEast - rawWest) >= 360 - EPSILON) return [{ west: -180, east: 180 }];
const normalizedWest = normalizeLongitudeDegrees(rawWest);
const normalizedEast = normalizeLongitudeDegrees(rawEast);
if (Math.abs(normalizedWest - normalizedEast) < EPSILON && Math.abs(rawEast - rawWest) > EPSILON) {
return [{ west: -180, east: 180 }];
}
if (normalizedWest <= normalizedEast) return [{ west: normalizedWest, east: normalizedEast }];
return [
{ west: normalizedWest, east: 180 },
{ west: -180, east: normalizedEast },
];
}
export function alignedGridValues(minimum, maximum, step, options = {}) {
const safeStep = Math.max(EPSILON, finite(step, 1));
const min = finite(minimum, 0);
const max = finite(maximum, min);
const includeMaximum = options.includeMaximum !== false;
const first = Math.ceil((min - EPSILON) / safeStep);
const last = includeMaximum
? Math.floor((max + EPSILON) / safeStep)
: Math.ceil((max - EPSILON) / safeStep) - 1;
const values = [];
for (let index = first; index <= last; index += 1) values.push(canonicalZero(index * safeStep));
return values;
}
export function boundedAngularParts(start, end, maximumSpanDegrees = 90) {
const minimum = finite(start, 0);
const maximum = finite(end, minimum);
const maximumSpan = clamp(finite(maximumSpanDegrees, 90), 0.1, 90);
if (maximum <= minimum) return [];
const parts = [];
for (let partStart = minimum; partStart < maximum; partStart += maximumSpan) {
parts.push({ start: partStart, end: Math.min(maximum, partStart + maximumSpan) });
}
return parts;
}
export function graticuleGranularity(stepDegrees, clampToGround) {
const radians = clamp(finite(stepDegrees, 1), 0.1, 180) * Math.PI / 180;
// Cesium's PolylineGeometry consumes angular granularity, while
// GroundPolylineGeometry consumes a surface distance in metres.
return clampToGround ? radians * WGS84_EQUATORIAL_RADIUS_METERS : radians;
}
export function graticuleLinePlan({ south, north, longitudeIntervals, stepDegrees }) {
const step = clamp(finite(stepDegrees, 1), 0.1, 180);
const safeSouth = clamp(finite(south, -89.9), -89.9, 89.9);
const safeNorth = clamp(finite(north, 89.9), -89.9, 89.9);
const intervals = longitudeIntervals?.length
? longitudeIntervals
: [{ west: -180, east: 180 }];
const parallels = alignedGridValues(safeSouth, safeNorth, step)
.filter((latitude) => latitude > -90 + EPSILON && latitude < 90 - EPSILON);
const meridians = [];
const seen = new Set();
for (const interval of intervals) {
for (const longitude of alignedGridValues(interval.west, interval.east, step, { includeMaximum: interval.east < 180 })) {
const normalized = normalizeLongitudeDegrees(longitude);
const key = decimalToken(normalized, 9);
if (seen.has(key)) continue;
seen.add(key);
meridians.push({ longitude: normalized, interval });
}
}
return { stepDegrees: step, south: safeSouth, north: safeNorth, longitudeIntervals: intervals, parallels, meridians };
}