942 lines
38 KiB
JavaScript
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 };
|
|
}
|