feat(map): add fixed ENU and WGS84 sector grid

This commit is contained in:
Codex
2026-08-06 10:31:03 +03:00
parent 80e948c018
commit 6f1fcb1eb0
15 changed files with 1806 additions and 362 deletions
+211
View File
@@ -0,0 +1,211 @@
const EPSILON = 1e-9;
const WGS84_EQUATORIAL_RADIUS_METERS = 6_378_137;
export const MAX_LOCAL_GRID_INDEX = 512;
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.");
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);
}
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),
};
}
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 };
}