feat(map): add fixed ENU and WGS84 sector grid
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user