feat(map): add fixed ENU and WGS84 sector grid
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import {
|
||||
ArcType,
|
||||
Cartesian2,
|
||||
Cartesian3,
|
||||
BingMapsImageryProvider,
|
||||
@@ -35,15 +36,18 @@ import {
|
||||
Math as CesiumMath,
|
||||
PerInstanceColorAppearance,
|
||||
PointGraphics,
|
||||
PointPrimitiveCollection,
|
||||
PolygonGeometry,
|
||||
PolygonHierarchy,
|
||||
PolylineColorAppearance,
|
||||
PolylineGraphics,
|
||||
PolylineCollection,
|
||||
Resource,
|
||||
sampleTerrainMostDetailed,
|
||||
ScreenSpaceEventHandler,
|
||||
ScreenSpaceEventType,
|
||||
SunLight,
|
||||
Transforms,
|
||||
VerticalOrigin,
|
||||
Viewer,
|
||||
} from "cesium";
|
||||
@@ -65,8 +69,19 @@ import {
|
||||
gridShouldBeVisible,
|
||||
resolveGridMode,
|
||||
selectGridLod,
|
||||
snapGridCenter,
|
||||
} from "./mapGridPolicy.mjs";
|
||||
import {
|
||||
boundedAngularParts,
|
||||
fixedGridOrigin,
|
||||
graticuleGranularity,
|
||||
graticuleLinePlan,
|
||||
graticuleSectorAt,
|
||||
graticuleSectorBounds,
|
||||
localGridPlan,
|
||||
localSectorAt,
|
||||
localSectorBounds,
|
||||
splitLongitudeRange,
|
||||
} from "./mapSectorGrid.mjs";
|
||||
|
||||
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
||||
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
||||
@@ -220,12 +235,13 @@ export type MapPresentation = {
|
||||
gridLodEnabled: boolean;
|
||||
grid3dEnabled: boolean;
|
||||
gridGraticuleEnabled: boolean;
|
||||
gridCenterMode: "camera" | "fixed";
|
||||
gridCenterMode: "fixed";
|
||||
gridCenterLatitude: number;
|
||||
gridCenterLongitude: number;
|
||||
gridTileSizeKm: number;
|
||||
gridAutoDisableHeightKm: number;
|
||||
gridRebuildOnMoveEnd: boolean;
|
||||
gridLegacyMode: boolean;
|
||||
gridMax3dViewAngleDegrees: number;
|
||||
gridHeightMeters: number;
|
||||
gridLod1MaxHeightKm: number;
|
||||
@@ -241,6 +257,7 @@ export type MapPresentation = {
|
||||
gridLod4StepKm: number;
|
||||
gridLod4Mode: "3d" | "graticule";
|
||||
gridLod5StepKm: number;
|
||||
gridLod5MaxHeightKm: number;
|
||||
gridLod5Mode: "3d" | "graticule";
|
||||
gridRadiusKm: number;
|
||||
gridLineWidth: number;
|
||||
@@ -257,9 +274,45 @@ export type MapPresentation = {
|
||||
gridCrossesWidthMeters: number;
|
||||
gridCrossesColor: string;
|
||||
gridCrossesOpacity: number;
|
||||
gridLodProfiles: GridLodProfile[];
|
||||
cacheRefresh: boolean;
|
||||
};
|
||||
|
||||
export type GridLodProfile = {
|
||||
maxHeightKm: number;
|
||||
stepKm: number;
|
||||
mode: "3d" | "graticule";
|
||||
heightMeters: number;
|
||||
max3dViewAngleDegrees: number;
|
||||
tileSizeKm: number;
|
||||
radiusKm: number;
|
||||
lineDiameterMeters: number;
|
||||
lineColor: string;
|
||||
lineOpacity: number;
|
||||
dotsEnabled: boolean;
|
||||
dotsDiameterMeters: number;
|
||||
dotsColor: string;
|
||||
dotsOpacity: number;
|
||||
crossesEnabled: boolean;
|
||||
crossesLengthMeters: number;
|
||||
crossesWidthMeters: number;
|
||||
crossesColor: string;
|
||||
crossesOpacity: number;
|
||||
graticuleStepDegrees: number;
|
||||
graticuleLineWidthPx: number;
|
||||
graticuleColor: string;
|
||||
graticuleOpacity: number;
|
||||
};
|
||||
|
||||
export type GridSectorSelection = {
|
||||
id: string;
|
||||
lod: number;
|
||||
mode: "3d" | "graticule";
|
||||
address: { eastIndex: number; northIndex: number } | { longitudeIndex: number; latitudeIndex: number };
|
||||
bounds: { west: number; east: number; south: number; north: number };
|
||||
units: "meters-enu" | "degrees-wgs84";
|
||||
};
|
||||
|
||||
export type MapCameraView = {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
@@ -903,195 +956,414 @@ function syncRuntimeDataSources(
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
type GridLayerBuild = {
|
||||
dataSource: CustomDataSource | null;
|
||||
key: string;
|
||||
lodIndex: number | null;
|
||||
type GridLodBand = GridLodProfile & { index: number; id: string };
|
||||
type LocalGridAddressing = {
|
||||
mode: "3d";
|
||||
lod: number;
|
||||
stepMeters: number;
|
||||
radiusMeters: number;
|
||||
originLatitude: number;
|
||||
originLongitude: number;
|
||||
inverseEnu: Matrix4;
|
||||
};
|
||||
type GraticuleGridAddressing = {
|
||||
mode: "graticule";
|
||||
lod: number;
|
||||
stepDegrees: number;
|
||||
};
|
||||
type GridAddressing = LocalGridAddressing | GraticuleGridAddressing;
|
||||
type GridResources = {
|
||||
dataSource: CustomDataSource;
|
||||
points: PointPrimitiveCollection | null;
|
||||
crosses: PolylineCollection | null;
|
||||
};
|
||||
type HiddenGridPlan = { mode: "hidden"; key: string; lodIndex: number | null };
|
||||
type LocalGridLayerPlan = {
|
||||
mode: "3d";
|
||||
key: string;
|
||||
lodIndex: number;
|
||||
lod: GridLodBand;
|
||||
origin: { latitude: number; longitude: number };
|
||||
enu: Matrix4;
|
||||
inverseEnu: Matrix4;
|
||||
grid: ReturnType<typeof localGridPlan>;
|
||||
metersPerPixel: number;
|
||||
cameraHeightMeters: number;
|
||||
cameraPosition: Cartesian3;
|
||||
cameraDirection: Cartesian3;
|
||||
};
|
||||
type GraticuleGridLayerPlan = {
|
||||
mode: "graticule";
|
||||
key: string;
|
||||
lodIndex: number;
|
||||
lod: GridLodBand;
|
||||
grid: ReturnType<typeof graticuleLinePlan>;
|
||||
};
|
||||
type GridLayerPlan = HiddenGridPlan | LocalGridLayerPlan | GraticuleGridLayerPlan;
|
||||
|
||||
function buildGridLayer(
|
||||
viewer: Viewer,
|
||||
presentation: MapPresentation,
|
||||
previousLodIndex: number | null,
|
||||
serial: number,
|
||||
): GridLayerBuild {
|
||||
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000);
|
||||
if (!gridShouldBeVisible(presentation, cameraHeightKm)) {
|
||||
return { dataSource: null, key: "hidden", lodIndex: null };
|
||||
function gridMetersPerPixel(viewer: Viewer) {
|
||||
const frustum = viewer.camera.frustum as unknown as { fovy?: number };
|
||||
return Math.max(0.01, (
|
||||
Math.max(1, Number(viewer.camera.positionCartographic?.height || 1))
|
||||
* 2 * Math.tan((Number(frustum.fovy) || Math.PI / 3) / 2)
|
||||
) / Math.max(1, viewer.scene.canvas.clientHeight));
|
||||
}
|
||||
|
||||
function graticuleViewport(viewer: Viewer, stepDegrees: number, cameraHeightKm: number) {
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
const margin = Math.max(stepDegrees * 2, Math.min(30, cameraHeightKm / 200));
|
||||
const alignLatitude = (value: number, direction: "down" | "up") => clamp(
|
||||
(direction === "down" ? Math.floor(value / stepDegrees) : Math.ceil(value / stepDegrees)) * stepDegrees,
|
||||
-89.9,
|
||||
89.9,
|
||||
);
|
||||
const alignIntervals = (west: number, east: number) => splitLongitudeRange(west - margin, east + margin).map((interval) => ({
|
||||
west: interval.west <= -180 ? -180 : Math.floor(interval.west / stepDegrees) * stepDegrees,
|
||||
east: interval.east >= 180 ? 180 : Math.ceil(interval.east / stepDegrees) * stepDegrees,
|
||||
}));
|
||||
const rectangle = viewer.camera.computeViewRectangle(ellipsoid);
|
||||
if (rectangle) {
|
||||
const south = CesiumMath.toDegrees(rectangle.south);
|
||||
const north = CesiumMath.toDegrees(rectangle.north);
|
||||
const west = CesiumMath.toDegrees(rectangle.west);
|
||||
const east = CesiumMath.toDegrees(rectangle.east);
|
||||
return {
|
||||
south: alignLatitude(south - margin, "down"),
|
||||
north: alignLatitude(north + margin, "up"),
|
||||
longitudeIntervals: alignIntervals(west, east),
|
||||
};
|
||||
}
|
||||
|
||||
const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex);
|
||||
const pitchDegrees = CesiumMath.toDegrees(viewer.camera.pitch);
|
||||
const viewAngleFromNadir = Math.abs(90 - Math.abs(pitchDegrees));
|
||||
const mode = resolveGridMode(presentation, lod.mode, viewAngleFromNadir);
|
||||
if (mode === "hidden") return { dataSource: null, key: "hidden", lodIndex: lod.index };
|
||||
|
||||
const safeStepKm = clamp(lod.stepKm, 0.25, 5_000);
|
||||
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 2_000);
|
||||
const stepsPerSide = Math.min(40, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
|
||||
const cameraPosition = viewer.camera.positionCartographic;
|
||||
const center = snapGridCenter({
|
||||
latitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244,
|
||||
longitude: cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423,
|
||||
}, presentation);
|
||||
const latitude = center.latitude;
|
||||
const longitude = center.longitude;
|
||||
const key = JSON.stringify([
|
||||
lod.index,
|
||||
mode,
|
||||
latitude,
|
||||
longitude,
|
||||
safeStepKm,
|
||||
safeRadiusKm,
|
||||
presentation.gridHeightMeters,
|
||||
presentation.gridLineWidth,
|
||||
presentation.gridLineDiameterMeters,
|
||||
presentation.gridColor,
|
||||
presentation.gridOpacity,
|
||||
presentation.gridDotsEnabled,
|
||||
presentation.gridDotsDiameterMeters,
|
||||
presentation.gridDotsColor,
|
||||
presentation.gridDotsOpacity,
|
||||
presentation.gridCrossesEnabled,
|
||||
presentation.gridCrossesLengthMeters,
|
||||
presentation.gridCrossesWidthMeters,
|
||||
presentation.gridCrossesColor,
|
||||
presentation.gridCrossesOpacity,
|
||||
]);
|
||||
const dataSource = new CustomDataSource(`nodedc-map-grid:${serial}`);
|
||||
const entities = dataSource.entities;
|
||||
const metersPerLatitudeDegree = 110_574;
|
||||
const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude)));
|
||||
const stepMeters = safeStepKm * 1000;
|
||||
const radiusMeters = stepsPerSide * stepMeters;
|
||||
const deltaLatitude = stepMeters / metersPerLatitudeDegree;
|
||||
const deltaLongitude = stepMeters / metersPerLongitudeDegree;
|
||||
const radiusLatitude = radiusMeters / metersPerLatitudeDegree;
|
||||
const radiusLongitude = radiusMeters / metersPerLongitudeDegree;
|
||||
const lineColor = Color.fromCssColorString(presentation.gridColor).withAlpha(clamp(presentation.gridOpacity / 100, 0, 1));
|
||||
const dotColor = Color.fromCssColorString(presentation.gridDotsColor).withAlpha(clamp(presentation.gridDotsOpacity / 100, 0, 1));
|
||||
const crossColor = Color.fromCssColorString(presentation.gridCrossesColor).withAlpha(clamp(presentation.gridCrossesOpacity / 100, 0, 1));
|
||||
const elevation = Math.max(0, presentation.gridHeightMeters);
|
||||
const projected = mode === "graticule";
|
||||
const geometryHeight = projected ? 0 : elevation;
|
||||
const addGridLine = (id: string, positions: Cartesian3[]) => {
|
||||
if (projected) {
|
||||
entities.add({
|
||||
id,
|
||||
polyline: {
|
||||
positions,
|
||||
width: clamp(presentation.gridLineWidth, 1, 8),
|
||||
material: lineColor,
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
entities.add({
|
||||
id,
|
||||
corridor: {
|
||||
positions,
|
||||
width: clamp(presentation.gridLineDiameterMeters, 1, 500),
|
||||
material: lineColor,
|
||||
height: geometryHeight,
|
||||
heightReference: HeightReference.NONE,
|
||||
},
|
||||
});
|
||||
const camera = viewer.camera.positionCartographic;
|
||||
const latitude = camera ? CesiumMath.toDegrees(camera.latitude) : 0;
|
||||
const longitude = camera ? CesiumMath.toDegrees(camera.longitude) : 0;
|
||||
const horizon = CesiumMath.toDegrees(Math.acos(clamp(
|
||||
ellipsoid.maximumRadius / (ellipsoid.maximumRadius + Math.max(0, cameraHeightKm * 1_000)),
|
||||
-1,
|
||||
1,
|
||||
)));
|
||||
const range = Math.min(180, Math.max(10, horizon * 2));
|
||||
return {
|
||||
south: alignLatitude(latitude - range - margin, "down"),
|
||||
north: alignLatitude(latitude + range + margin, "up"),
|
||||
longitudeIntervals: alignIntervals(longitude - range - margin, longitude + range + margin),
|
||||
};
|
||||
}
|
||||
|
||||
for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) {
|
||||
const nextLatitude = latitude + index * deltaLatitude;
|
||||
const nextLongitude = longitude + index * deltaLongitude;
|
||||
addGridLine(`${serial}:latitude:${index}`, [
|
||||
Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, geometryHeight),
|
||||
Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, geometryHeight),
|
||||
]);
|
||||
addGridLine(`${serial}:longitude:${index}`, [
|
||||
Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, geometryHeight),
|
||||
Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, geometryHeight),
|
||||
function planGridLayer(viewer: Viewer, presentation: MapPresentation, previousLodIndex: number | null): GridLayerPlan {
|
||||
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1_000);
|
||||
if (!gridShouldBeVisible(presentation, cameraHeightKm)) return { mode: "hidden", key: "hidden", lodIndex: null };
|
||||
const lod = selectGridLod(presentation, cameraHeightKm, previousLodIndex) as GridLodBand;
|
||||
const mode = resolveGridMode(presentation, lod.mode);
|
||||
if (mode === "hidden") return { mode: "hidden", key: `hidden:${lod.index}`, lodIndex: lod.index };
|
||||
|
||||
if (mode === "3d") {
|
||||
const origin = fixedGridOrigin(presentation);
|
||||
const anchor = Cartesian3.fromDegrees(origin.longitude, origin.latitude, 0);
|
||||
const enu = Transforms.eastNorthUpToFixedFrame(anchor);
|
||||
const inverseEnu = Matrix4.inverseTransformation(enu, new Matrix4());
|
||||
const stepMeters = clamp(lod.stepKm * 1_000, 100, 5_000_000);
|
||||
const radiusMeters = clamp(lod.radiusKm * 1_000, stepMeters, 100_000_000);
|
||||
const metersPerPixel = gridMetersPerPixel(viewer);
|
||||
const cameraPosition = Cartesian3.clone(viewer.camera.positionWC, new Cartesian3());
|
||||
const cameraDirection = Cartesian3.normalize(viewer.camera.directionWC, new Cartesian3());
|
||||
const cameraLocal = Matrix4.multiplyByPoint(inverseEnu, cameraPosition, new Cartesian3());
|
||||
const cameraPositionBucketMeters = Math.max(stepMeters, lod.tileSizeKm * 500);
|
||||
const cameraPositionKey = [cameraLocal.x, cameraLocal.y]
|
||||
.map((value) => Math.round(value / cameraPositionBucketMeters));
|
||||
const directionKey = [cameraDirection.x, cameraDirection.y, cameraDirection.z]
|
||||
.map((value) => Math.round(value * 50) / 50);
|
||||
const cameraHeightMeters = cameraHeightKm * 1_000;
|
||||
const grid = localGridPlan({ stepMeters, radiusMeters, maximumMarkers: 5_000 });
|
||||
const key = JSON.stringify([
|
||||
"local-enu", lod.index, origin.latitude, origin.longitude, stepMeters, radiusMeters,
|
||||
lod.heightMeters, lod.max3dViewAngleDegrees, lod.tileSizeKm,
|
||||
lod.lineDiameterMeters, lod.lineColor, lod.lineOpacity,
|
||||
lod.dotsEnabled, lod.dotsDiameterMeters, lod.dotsColor, lod.dotsOpacity,
|
||||
lod.crossesEnabled, lod.crossesLengthMeters, lod.crossesWidthMeters, lod.crossesColor, lod.crossesOpacity,
|
||||
grid.markerStride, cameraPositionKey, directionKey, Math.round(cameraHeightKm * 10) / 10,
|
||||
Math.round(metersPerPixel * 10) / 10,
|
||||
]);
|
||||
return {
|
||||
mode, key, lodIndex: lod.index, lod, origin, enu, inverseEnu, grid,
|
||||
metersPerPixel, cameraHeightMeters, cameraPosition, cameraDirection,
|
||||
};
|
||||
}
|
||||
|
||||
const dotStride = Math.max(1, Math.ceil((stepsPerSide * 2 + 1) / 25));
|
||||
for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) {
|
||||
for (let column = -stepsPerSide; column <= stepsPerSide; column += dotStride) {
|
||||
const dotLongitude = longitude + column * deltaLongitude;
|
||||
const dotLatitude = latitude + row * deltaLatitude;
|
||||
if (presentation.gridDotsEnabled) {
|
||||
const diameterMeters = clamp(presentation.gridDotsDiameterMeters, 2, 2_000);
|
||||
entities.add({
|
||||
id: `${serial}:circle:${row}:${column}`,
|
||||
position: Cartesian3.fromDegrees(dotLongitude, dotLatitude, geometryHeight),
|
||||
ellipse: {
|
||||
semiMajorAxis: diameterMeters / 2,
|
||||
semiMinorAxis: diameterMeters / 2,
|
||||
material: dotColor,
|
||||
height: geometryHeight,
|
||||
heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE,
|
||||
const viewport = graticuleViewport(viewer, lod.graticuleStepDegrees, cameraHeightKm);
|
||||
const grid = graticuleLinePlan({ ...viewport, stepDegrees: lod.graticuleStepDegrees });
|
||||
const key = JSON.stringify([
|
||||
"wgs84-graticule", lod.index, lod.heightMeters, lod.graticuleStepDegrees,
|
||||
lod.graticuleLineWidthPx, lod.graticuleColor, lod.graticuleOpacity,
|
||||
grid.south, grid.north, grid.longitudeIntervals,
|
||||
]);
|
||||
return { mode, key, lodIndex: lod.index, lod, grid };
|
||||
}
|
||||
|
||||
function materializeLocalGrid(plan: LocalGridLayerPlan, serial: number): GridResources {
|
||||
const dataSource = new CustomDataSource(`nodedc-map-grid-local:${serial}`);
|
||||
const points = plan.lod.dotsEnabled ? new PointPrimitiveCollection() : null;
|
||||
const crosses = plan.lod.crossesEnabled ? new PolylineCollection() : null;
|
||||
const lineColor = Color.fromCssColorString(plan.lod.lineColor).withAlpha(clamp(plan.lod.lineOpacity / 100, 0, 1));
|
||||
const dotColor = Color.fromCssColorString(plan.lod.dotsColor).withAlpha(clamp(plan.lod.dotsOpacity / 100, 0, 1));
|
||||
const crossColor = Color.fromCssColorString(plan.lod.crossesColor).withAlpha(clamp(plan.lod.crossesOpacity / 100, 0, 1));
|
||||
const lineWidthPixels = clamp(plan.lod.lineDiameterMeters / plan.metersPerPixel, 1, 8);
|
||||
const dotPixelSize = clamp(plan.lod.dotsDiameterMeters / plan.metersPerPixel, 1, 128);
|
||||
const crossWidthPixels = clamp(plan.lod.crossesWidthMeters / plan.metersPerPixel, 1, 8);
|
||||
const definition = {
|
||||
lod: plan.lod.index + 1,
|
||||
originLatitude: plan.origin.latitude,
|
||||
originLongitude: plan.origin.longitude,
|
||||
stepMeters: plan.grid.stepMeters,
|
||||
};
|
||||
const toWorld = (eastMeters: number, northMeters: number) => {
|
||||
const tangentPoint = Matrix4.multiplyByPoint(plan.enu, new Cartesian3(eastMeters, northMeters, 0), new Cartesian3());
|
||||
const cartographic = Cartographic.fromCartesian(tangentPoint);
|
||||
// Metric addressing stays in the immutable ENU frame. Reprojection only
|
||||
// bends the visual shell to WGS84 at an absolute height; terrain and 3D
|
||||
// Tiles never move the sector boundaries.
|
||||
return Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, Math.max(0, plan.lod.heightMeters));
|
||||
};
|
||||
const useViewCone = Number.isFinite(plan.lod.max3dViewAngleDegrees) && plan.lod.max3dViewAngleDegrees < 170;
|
||||
const viewConeMinimumDot = Math.cos(CesiumMath.toRadians(plan.lod.max3dViewAngleDegrees));
|
||||
const viewConeMinimumDistance = Math.max(
|
||||
plan.grid.stepMeters * 2,
|
||||
Math.min(plan.grid.radiusMeters, plan.cameraHeightMeters * 6),
|
||||
);
|
||||
const cameraLocal = Matrix4.multiplyByPoint(plan.inverseEnu, plan.cameraPosition, new Cartesian3());
|
||||
const cameraDirectionLocal = Matrix4.multiplyByPointAsVector(plan.inverseEnu, plan.cameraDirection, new Cartesian3());
|
||||
Cartesian3.normalize(cameraDirectionLocal, cameraDirectionLocal);
|
||||
const originLatitudeRadians = CesiumMath.toRadians(plan.origin.latitude);
|
||||
const wgs84SemiMajor = 6_378_137;
|
||||
const wgs84EccentricitySquared = 0.00669437999014;
|
||||
const latitudeFactor = 1 - wgs84EccentricitySquared * Math.sin(originLatitudeRadians) ** 2;
|
||||
const eastCurvatureRadius = wgs84SemiMajor / Math.sqrt(latitudeFactor);
|
||||
const northCurvatureRadius = wgs84SemiMajor * (1 - wgs84EccentricitySquared) / latitudeFactor ** 1.5;
|
||||
const approximateShellPoint = (eastMeters: number, northMeters: number) => {
|
||||
const horizontalSquared = eastMeters ** 2 + northMeters ** 2;
|
||||
if (horizontalSquared <= 1e-9) return new Cartesian3(0, 0, plan.lod.heightMeters);
|
||||
const curvatureRadius = horizontalSquared / (
|
||||
eastMeters ** 2 / eastCurvatureRadius + northMeters ** 2 / northCurvatureRadius
|
||||
);
|
||||
// Invert the donor's tangent-point -> geodetic-direction reprojection.
|
||||
// This keeps cone culling close to the WGS84 shell without paying for two
|
||||
// Cartographic conversions on every coarse candidate.
|
||||
const scale = curvatureRadius / Math.sqrt(curvatureRadius ** 2 + horizontalSquared);
|
||||
return new Cartesian3(
|
||||
eastMeters * scale,
|
||||
northMeters * scale,
|
||||
curvatureRadius * scale - curvatureRadius + plan.lod.heightMeters,
|
||||
);
|
||||
};
|
||||
const inViewCone = (eastMeters: number, northMeters: number) => {
|
||||
if (!useViewCone) return true;
|
||||
const shellPoint = approximateShellPoint(eastMeters, northMeters);
|
||||
const direction = Cartesian3.subtract(shellPoint, cameraLocal, new Cartesian3());
|
||||
const distance = Cartesian3.magnitude(direction);
|
||||
if (!Number.isFinite(distance) || distance <= viewConeMinimumDistance) return true;
|
||||
Cartesian3.normalize(direction, direction);
|
||||
const approximateDot = Cartesian3.dot(direction, cameraDirectionLocal);
|
||||
if (Math.abs(approximateDot - viewConeMinimumDot) > 0.15) return approximateDot >= viewConeMinimumDot;
|
||||
// ENU-plane culling is cheap, but the rendered lattice is bent back to
|
||||
// the WGS84 shell. Resolve candidates near the cone boundary in world
|
||||
// space so curvature cannot hide the sector beneath an oblique camera.
|
||||
const worldDirection = Cartesian3.subtract(toWorld(eastMeters, northMeters), plan.cameraPosition, new Cartesian3());
|
||||
Cartesian3.normalize(worldDirection, worldDirection);
|
||||
return Cartesian3.dot(worldDirection, plan.cameraDirection) >= viewConeMinimumDot;
|
||||
};
|
||||
const segmentStepMeters = Math.max(
|
||||
plan.grid.stepMeters,
|
||||
plan.lod.tileSizeKm * 1_000,
|
||||
plan.grid.radiusMeters / 80,
|
||||
);
|
||||
const addSegmentedLine = (axis: "east" | "north", line: (typeof plan.grid.lines)[number]) => {
|
||||
let positions: Cartesian3[] = [];
|
||||
let segmentStart = 0;
|
||||
const flush = () => {
|
||||
if (positions.length >= 2) {
|
||||
const address = axis === "east"
|
||||
? localSectorAt({ eastMeters: line.offsetMeters, northMeters: 0 }, definition)
|
||||
: localSectorAt({ eastMeters: 0, northMeters: line.offsetMeters }, definition);
|
||||
dataSource.entities.add({
|
||||
id: `${address.id}/line-${axis}/part-${segmentStart}`,
|
||||
polyline: {
|
||||
positions,
|
||||
width: lineWidthPixels,
|
||||
material: lineColor,
|
||||
clampToGround: false,
|
||||
arcType: ArcType.NONE,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (presentation.gridCrossesEnabled) {
|
||||
const halfLengthMeters = clamp(presentation.gridCrossesLengthMeters, 2, 5_000) / 2;
|
||||
const halfLatitude = halfLengthMeters / metersPerLatitudeDegree;
|
||||
const halfLongitude = halfLengthMeters / metersPerLongitudeDegree;
|
||||
const corridor = (positions: Cartesian3[]) => ({
|
||||
positions,
|
||||
width: clamp(presentation.gridCrossesWidthMeters, 1, 500),
|
||||
material: crossColor,
|
||||
height: geometryHeight,
|
||||
heightReference: projected ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE,
|
||||
positions = [];
|
||||
};
|
||||
let part = 0;
|
||||
for (let variable = -line.extentMeters; variable < line.extentMeters; variable += segmentStepMeters) {
|
||||
const next = Math.min(variable + segmentStepMeters, line.extentMeters);
|
||||
const middle = (variable + next) / 2;
|
||||
const middleEast = axis === "east" ? line.offsetMeters : middle;
|
||||
const middleNorth = axis === "east" ? middle : line.offsetMeters;
|
||||
if (!inViewCone(middleEast, middleNorth)) {
|
||||
flush();
|
||||
part += 1;
|
||||
segmentStart = part;
|
||||
continue;
|
||||
}
|
||||
const start = axis === "east" ? toWorld(line.offsetMeters, variable) : toWorld(variable, line.offsetMeters);
|
||||
const end = axis === "east" ? toWorld(line.offsetMeters, next) : toWorld(next, line.offsetMeters);
|
||||
if (positions.length === 0) positions.push(start);
|
||||
positions.push(end);
|
||||
part += 1;
|
||||
}
|
||||
flush();
|
||||
};
|
||||
for (const line of plan.grid.lines) addSegmentedLine("east", line);
|
||||
for (const line of plan.grid.lines) addSegmentedLine("north", line);
|
||||
|
||||
const radiusSquared = plan.grid.radiusMeters ** 2;
|
||||
const crossHalf = plan.lod.crossesLengthMeters / 2;
|
||||
for (let eastIndex = -plan.grid.maximumIndex; eastIndex <= plan.grid.maximumIndex; eastIndex += plan.grid.markerStride) {
|
||||
const eastMeters = eastIndex * plan.grid.stepMeters;
|
||||
for (let northIndex = -plan.grid.maximumIndex; northIndex <= plan.grid.maximumIndex; northIndex += plan.grid.markerStride) {
|
||||
const northMeters = northIndex * plan.grid.stepMeters;
|
||||
if (eastMeters ** 2 + northMeters ** 2 > radiusSquared) continue;
|
||||
if (!inViewCone(eastMeters, northMeters)) continue;
|
||||
const position = toWorld(eastMeters, northMeters);
|
||||
const address = localSectorAt({ eastMeters, northMeters }, definition);
|
||||
if (points) points.add({
|
||||
id: { kind: "nodedc-grid-intersection", sectorId: address.id },
|
||||
position,
|
||||
color: dotColor,
|
||||
pixelSize: Math.max(1, Math.round(dotPixelSize)),
|
||||
});
|
||||
if (crosses && crossHalf > 0) {
|
||||
const eastWest = crosses.add({
|
||||
id: { kind: "nodedc-grid-cross", sectorId: address.id },
|
||||
positions: [toWorld(eastMeters - crossHalf, northMeters), toWorld(eastMeters + crossHalf, northMeters)],
|
||||
width: Math.max(1, Math.round(crossWidthPixels)),
|
||||
});
|
||||
entities.add({
|
||||
id: `${serial}:cross-ns:${row}:${column}`,
|
||||
corridor: corridor([
|
||||
Cartesian3.fromDegrees(dotLongitude, dotLatitude - halfLatitude, geometryHeight),
|
||||
Cartesian3.fromDegrees(dotLongitude, dotLatitude + halfLatitude, geometryHeight),
|
||||
]),
|
||||
});
|
||||
entities.add({
|
||||
id: `${serial}:cross-ew:${row}:${column}`,
|
||||
corridor: corridor([
|
||||
Cartesian3.fromDegrees(dotLongitude - halfLongitude, dotLatitude, geometryHeight),
|
||||
Cartesian3.fromDegrees(dotLongitude + halfLongitude, dotLatitude, geometryHeight),
|
||||
]),
|
||||
const northSouth = crosses.add({
|
||||
id: { kind: "nodedc-grid-cross", sectorId: address.id },
|
||||
positions: [toWorld(eastMeters, northMeters - crossHalf), toWorld(eastMeters, northMeters + crossHalf)],
|
||||
width: Math.max(1, Math.round(crossWidthPixels)),
|
||||
});
|
||||
if (eastWest.material?.uniforms) eastWest.material.uniforms.color = crossColor;
|
||||
if (northSouth.material?.uniforms) northSouth.material.uniforms.color = crossColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { dataSource, key, lodIndex: lod.index };
|
||||
return { dataSource, points, crosses };
|
||||
}
|
||||
|
||||
function materializeGraticule(plan: GraticuleGridLayerPlan, serial: number): GridResources {
|
||||
const dataSource = new CustomDataSource(`nodedc-map-grid-graticule:${serial}`);
|
||||
const color = Color.fromCssColorString(plan.lod.graticuleColor).withAlpha(clamp(plan.lod.graticuleOpacity / 100, 0, 1));
|
||||
const clampToGround = plan.lod.heightMeters <= 0;
|
||||
const height = clampToGround ? 0 : plan.lod.heightMeters;
|
||||
const sampleStep = Math.min(5, Math.max(0.5, plan.grid.stepDegrees));
|
||||
const line = (id: string, positions: Cartesian3[]) => positions.length >= 2 && dataSource.entities.add({
|
||||
id,
|
||||
polyline: {
|
||||
positions,
|
||||
width: plan.lod.graticuleLineWidthPx,
|
||||
material: color,
|
||||
clampToGround,
|
||||
arcType: ArcType.RHUMB,
|
||||
granularity: graticuleGranularity(sampleStep, clampToGround),
|
||||
},
|
||||
});
|
||||
const positionsForParts = (
|
||||
parts: Array<{ start: number; end: number }>,
|
||||
positionAt: (angle: number) => Cartesian3,
|
||||
) => parts.length === 0
|
||||
? []
|
||||
: [positionAt(parts[0].start), ...parts.map(({ end }) => positionAt(end))];
|
||||
for (const { longitude } of plan.grid.meridians) {
|
||||
const parts = boundedAngularParts(plan.grid.south, plan.grid.north);
|
||||
line(
|
||||
`grid/wgs84/l${plan.lod.index + 1}/meridian/${longitude}`,
|
||||
positionsForParts(parts, (latitude) => Cartesian3.fromDegrees(longitude, latitude, height)),
|
||||
);
|
||||
}
|
||||
for (const latitude of plan.grid.parallels) {
|
||||
plan.grid.longitudeIntervals.forEach((interval, index) => {
|
||||
// RHUMB interpolation is correct for parallels, but Cesium rejects an
|
||||
// exactly antipodal equatorial pair. Intermediate vertices keep every
|
||||
// segment below 90° without multiplying the number of Cesium entities.
|
||||
const parts = boundedAngularParts(interval.west, interval.east);
|
||||
line(
|
||||
`grid/wgs84/l${plan.lod.index + 1}/parallel/${latitude}/interval-${index}`,
|
||||
positionsForParts(parts, (longitude) => Cartesian3.fromDegrees(longitude, latitude, height)),
|
||||
);
|
||||
});
|
||||
}
|
||||
return { dataSource, points: null, crosses: null };
|
||||
}
|
||||
|
||||
function materializeGridLayer(plan: Exclude<GridLayerPlan, HiddenGridPlan>, serial: number) {
|
||||
return plan.mode === "3d" ? materializeLocalGrid(plan, serial) : materializeGraticule(plan, serial);
|
||||
}
|
||||
|
||||
class GridLayerController {
|
||||
private current: CustomDataSource | null = null;
|
||||
private pending: CustomDataSource | null = null;
|
||||
private current: { resources: GridResources; addressing: GridAddressing } | null = null;
|
||||
private pending: { resources: GridResources; addressing: GridAddressing } | null = null;
|
||||
private key: string | null = null;
|
||||
private lodIndex: number | null = null;
|
||||
private epoch = 0;
|
||||
private serial = 0;
|
||||
private removeReadyListener: (() => void) | null = null;
|
||||
private fallbackTimer: number | null = null;
|
||||
private retiredResources = new WeakSet<GridResources>();
|
||||
|
||||
constructor(private readonly viewer: Viewer) {}
|
||||
constructor(
|
||||
private readonly viewer: Viewer,
|
||||
private readonly onAddressingChange: () => void,
|
||||
) {}
|
||||
|
||||
private addressingKey(addressing: GridAddressing | undefined) {
|
||||
if (!addressing) return "hidden";
|
||||
return addressing.mode === "3d"
|
||||
? JSON.stringify([addressing.mode, addressing.lod, addressing.originLatitude, addressing.originLongitude, addressing.stepMeters])
|
||||
: JSON.stringify([addressing.mode, addressing.lod, addressing.stepDegrees]);
|
||||
}
|
||||
|
||||
rebuild(presentation: MapPresentation) {
|
||||
const build = buildGridLayer(this.viewer, presentation, this.lodIndex, ++this.serial);
|
||||
if (build.key === this.key) return;
|
||||
this.key = build.key;
|
||||
this.lodIndex = build.lodIndex;
|
||||
// Planning is intentionally cheap. Geometry is materialized only after
|
||||
// key comparison; repeated camera/settings notifications cannot create
|
||||
// and discard thousands of Cesium objects for an unchanged layer.
|
||||
const plan = planGridLayer(this.viewer, presentation, this.lodIndex);
|
||||
if (plan.key === this.key) return;
|
||||
this.key = plan.key;
|
||||
this.lodIndex = plan.lodIndex;
|
||||
const epoch = ++this.epoch;
|
||||
this.cancelPending();
|
||||
|
||||
if (!build.dataSource) {
|
||||
if (this.current) this.viewer.dataSources.remove(this.current, true);
|
||||
if (plan.mode === "hidden") {
|
||||
this.onAddressingChange();
|
||||
if (this.current) this.removeResources(this.current.resources);
|
||||
this.current = null;
|
||||
this.viewer.scene.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = build.dataSource;
|
||||
const addressing: GridAddressing = plan.mode === "3d" ? {
|
||||
mode: "3d",
|
||||
lod: plan.lod.index + 1,
|
||||
stepMeters: plan.grid.stepMeters,
|
||||
radiusMeters: plan.grid.radiusMeters,
|
||||
originLatitude: plan.origin.latitude,
|
||||
originLongitude: plan.origin.longitude,
|
||||
inverseEnu: plan.inverseEnu,
|
||||
} : {
|
||||
mode: "graticule",
|
||||
lod: plan.lod.index + 1,
|
||||
stepDegrees: plan.grid.stepDegrees,
|
||||
};
|
||||
const activeAddressing = this.pending?.addressing ?? this.current?.addressing;
|
||||
if (this.addressingKey(activeAddressing) !== this.addressingKey(addressing)) this.onAddressingChange();
|
||||
const resources = materializeGridLayer(plan, ++this.serial);
|
||||
const next = { resources, addressing };
|
||||
this.pending = next;
|
||||
void this.viewer.dataSources.add(next);
|
||||
this.mountResources(resources);
|
||||
let readyFrames = 0;
|
||||
const commit = () => {
|
||||
if (epoch !== this.epoch || this.pending !== next) return;
|
||||
@@ -1102,33 +1374,100 @@ class GridLayerController {
|
||||
const previous = this.current;
|
||||
this.current = next;
|
||||
this.pending = null;
|
||||
if (previous && previous !== next) this.viewer.dataSources.remove(previous, true);
|
||||
if (previous && previous !== next) this.removeResources(previous.resources);
|
||||
this.viewer.scene.requestRender();
|
||||
};
|
||||
this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => {
|
||||
readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0;
|
||||
if (readyFrames >= 2) commit();
|
||||
else this.viewer.scene.requestRender();
|
||||
});
|
||||
// A slow terrain worker must not leave an obsolete buffer mounted forever.
|
||||
// The old layer stays visible during this grace period, so the fallback is
|
||||
// bounded cleanup rather than a visible blank-before-build swap.
|
||||
this.fallbackTimer = window.setTimeout(commit, 2_000);
|
||||
this.viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
pick(worldPosition: Cartesian3): GridSectorSelection | null {
|
||||
const addressing = this.pending?.addressing ?? this.current?.addressing;
|
||||
if (!addressing) return null;
|
||||
if (addressing.mode === "3d") {
|
||||
const ellipsoid = this.viewer.scene.globe.ellipsoid;
|
||||
const surface = ellipsoid.scaleToGeodeticSurface(worldPosition, new Cartesian3()) ?? worldPosition;
|
||||
const normal = ellipsoid.geodeticSurfaceNormal(surface, new Cartesian3());
|
||||
const localSurface = Matrix4.multiplyByPoint(addressing.inverseEnu, surface, new Cartesian3());
|
||||
const localNormal = Matrix4.multiplyByPointAsVector(addressing.inverseEnu, normal, new Cartesian3());
|
||||
// Geometry is addressed on the origin's ENU tangent plane and then
|
||||
// reprojected along the WGS84 normal. Intersect that normal with the
|
||||
// same plane so analytic picking returns the exact source cell even at
|
||||
// the outer edge of a 1,000 km LOD.
|
||||
const normalScale = Math.abs(localNormal.z) > 1e-9 ? -localSurface.z / localNormal.z : 0;
|
||||
const local = new Cartesian3(
|
||||
localSurface.x + localNormal.x * normalScale,
|
||||
localSurface.y + localNormal.y * normalScale,
|
||||
0,
|
||||
);
|
||||
if (local.x ** 2 + local.y ** 2 > addressing.radiusMeters ** 2) return null;
|
||||
const definition = {
|
||||
lod: addressing.lod,
|
||||
originLatitude: addressing.originLatitude,
|
||||
originLongitude: addressing.originLongitude,
|
||||
stepMeters: addressing.stepMeters,
|
||||
};
|
||||
const address = localSectorAt({ eastMeters: local.x, northMeters: local.y }, definition);
|
||||
return {
|
||||
id: address.id,
|
||||
lod: addressing.lod,
|
||||
mode: "3d",
|
||||
address: { eastIndex: address.eastIndex, northIndex: address.northIndex },
|
||||
bounds: localSectorBounds(address, addressing.stepMeters),
|
||||
units: "meters-enu",
|
||||
};
|
||||
}
|
||||
const cartographic = Cartographic.fromCartesian(worldPosition);
|
||||
const address = graticuleSectorAt({
|
||||
longitude: CesiumMath.toDegrees(cartographic.longitude),
|
||||
latitude: CesiumMath.toDegrees(cartographic.latitude),
|
||||
}, { lod: addressing.lod, stepDegrees: addressing.stepDegrees });
|
||||
return {
|
||||
id: address.id,
|
||||
lod: addressing.lod,
|
||||
mode: "graticule",
|
||||
address: { longitudeIndex: address.longitudeIndex, latitudeIndex: address.latitudeIndex },
|
||||
bounds: graticuleSectorBounds(address, addressing.stepDegrees),
|
||||
units: "degrees-wgs84",
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.epoch += 1;
|
||||
this.cancelPending();
|
||||
if (this.current) this.viewer.dataSources.remove(this.current, true);
|
||||
if (this.current) this.removeResources(this.current.resources);
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
private mountResources(resources: GridResources) {
|
||||
const add = this.viewer.dataSources.add(resources.dataSource);
|
||||
void add.then((dataSource) => {
|
||||
if (this.retiredResources.has(resources) && !this.viewer.isDestroyed()) {
|
||||
this.viewer.dataSources.remove(dataSource, true);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
if (resources.points) this.viewer.scene.primitives.add(resources.points);
|
||||
if (resources.crosses) this.viewer.scene.primitives.add(resources.crosses);
|
||||
}
|
||||
|
||||
private removeResources(resources: GridResources) {
|
||||
this.retiredResources.add(resources);
|
||||
this.viewer.dataSources.remove(resources.dataSource, true);
|
||||
if (resources.points) this.viewer.scene.primitives.remove(resources.points);
|
||||
if (resources.crosses) this.viewer.scene.primitives.remove(resources.crosses);
|
||||
}
|
||||
|
||||
private cancelPending() {
|
||||
this.removeReadyListener?.();
|
||||
this.removeReadyListener = null;
|
||||
if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer);
|
||||
this.fallbackTimer = null;
|
||||
if (this.pending) this.viewer.dataSources.remove(this.pending, true);
|
||||
if (this.pending) this.removeResources(this.pending.resources);
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
@@ -1184,6 +1523,7 @@ function applyPresentation(
|
||||
|
||||
export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
onSelect?: (entityId: string) => void;
|
||||
onGridSectorSelect?: (sector: GridSectorSelection | null) => void;
|
||||
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
|
||||
onProviderStatus?: (status: MapProviderStatus) => void;
|
||||
onCameraChange?: (camera: MapCameraView) => void;
|
||||
@@ -1197,6 +1537,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
presentationFilters?: MapPresentationFilters;
|
||||
}>(function CesiumMapRenderer({
|
||||
onSelect,
|
||||
onGridSectorSelect,
|
||||
onGatewayHealth,
|
||||
onProviderStatus,
|
||||
onCameraChange,
|
||||
@@ -1224,6 +1565,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
const presentationProfilesRef = useRef(presentationProfiles);
|
||||
const presentationFiltersRef = useRef(presentationFilters);
|
||||
const onSelectRef = useRef(onSelect);
|
||||
const onGridSectorSelectRef = useRef(onGridSectorSelect);
|
||||
const onCameraChangeRef = useRef(onCameraChange);
|
||||
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
|
||||
const onReadyChangeRef = useRef(onReadyChange);
|
||||
@@ -1234,6 +1576,10 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
onSelectRef.current = onSelect;
|
||||
}, [onSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
onGridSectorSelectRef.current = onGridSectorSelect;
|
||||
}, [onGridSectorSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
onCameraChangeRef.current = onCameraChange;
|
||||
}, [onCameraChange]);
|
||||
@@ -1641,14 +1987,20 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer || viewer.isDestroyed()) return false;
|
||||
const entity = runtimeEntities([entityId])[0];
|
||||
const position = entity?.position?.getValue(viewer.clock.currentTime);
|
||||
if (!position) return false;
|
||||
const cartographic = Cartographic.fromCartesian(position);
|
||||
return focusCoordinates(
|
||||
CesiumMath.toDegrees(cartographic.longitude),
|
||||
CesiumMath.toDegrees(cartographic.latitude),
|
||||
);
|
||||
}, [focusCoordinates, runtimeEntities]);
|
||||
if (!entity) return false;
|
||||
|
||||
// A subject selection is an explicit navigation command. Using the
|
||||
// generic viewport-transfer helper here can produce an imperceptible or
|
||||
// invalid destination when the current screen centre misses the globe
|
||||
// (for example near the horizon). Cesium's entity-aware flight resolves
|
||||
// the time-dynamic position itself and restores the proven operational
|
||||
// map behaviour: centre the selected subject at an inspectable range.
|
||||
void viewer.flyTo(entity, {
|
||||
duration: 0.45,
|
||||
offset: new HeadingPitchRange(0, -0.9, 8_000),
|
||||
});
|
||||
return true;
|
||||
}, [runtimeEntities]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
startSpiralAnimation,
|
||||
@@ -1721,6 +2073,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
let gridController: GridLayerController | undefined;
|
||||
let removeGridCameraListener: (() => void) | undefined;
|
||||
let removeGridCameraChangedListener: (() => void) | undefined;
|
||||
let gridCameraChangedTimer: number | undefined;
|
||||
let gridResizeTimer: number | undefined;
|
||||
let removeRefreshRenderListener: (() => void) | undefined;
|
||||
let removeRenderErrorListener: (() => void) | undefined;
|
||||
const removeProviderFailureListeners: Array<() => void> = [];
|
||||
@@ -1791,7 +2145,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
viewer.scene.globe.depthTestAgainstTerrain = true;
|
||||
viewerRef.current = viewer;
|
||||
terrainRef.current = terrain;
|
||||
gridController = new GridLayerController(viewer);
|
||||
gridController = new GridLayerController(viewer, () => onGridSectorSelectRef.current?.(null));
|
||||
const rebuildGrid = () => gridController?.rebuild(presentationRef.current);
|
||||
rebuildGridRef.current = rebuildGrid;
|
||||
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
|
||||
@@ -1800,7 +2154,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
onCameraChangeRef.current?.(getCameraView(viewer!));
|
||||
});
|
||||
removeGridCameraChangedListener = viewer.camera.changed.addEventListener(() => {
|
||||
if (!presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid();
|
||||
if (presentationRef.current.gridRebuildOnMoveEnd || gridCameraChangedTimer !== undefined) return;
|
||||
gridCameraChangedTimer = window.setTimeout(() => {
|
||||
gridCameraChangedTimer = undefined;
|
||||
rebuildGrid();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
const providerStatus: MapProviderStatus = {
|
||||
@@ -1973,7 +2331,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
handler.setInputAction((movement: { position: Cartesian2 }) => {
|
||||
const picked = viewer?.scene.pick(movement.position);
|
||||
const pickedId = picked?.id;
|
||||
if (pickedId instanceof Entity && pickedId.id) {
|
||||
if (pickedId instanceof Entity && pickedId.id && !String(pickedId.id).startsWith("grid/")) {
|
||||
onGridSectorSelectRef.current?.(null);
|
||||
onSelectRef.current?.(pickedId.id);
|
||||
return;
|
||||
}
|
||||
@@ -1982,12 +2341,27 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
&& typeof pickedId === "object"
|
||||
&& (pickedId as Partial<HGeoZonePickId>).kind === "nodedc-hgeozone"
|
||||
&& typeof (pickedId as Partial<HGeoZonePickId>).entityId === "string"
|
||||
) onSelectRef.current?.((pickedId as HGeoZonePickId).entityId);
|
||||
) {
|
||||
onGridSectorSelectRef.current?.(null);
|
||||
onSelectRef.current?.((pickedId as HGeoZonePickId).entityId);
|
||||
return;
|
||||
}
|
||||
const ray = viewer?.camera.getPickRay(movement.position);
|
||||
const worldPosition = ray && viewer
|
||||
? viewer.scene.globe.pick(ray, viewer.scene) ?? viewer.camera.pickEllipsoid(movement.position, viewer.scene.globe.ellipsoid)
|
||||
: undefined;
|
||||
onGridSectorSelectRef.current?.(worldPosition ? gridController?.pick(worldPosition) ?? null : null);
|
||||
}, ScreenSpaceEventType.LEFT_CLICK);
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!viewer || viewer.isDestroyed()) return;
|
||||
viewer.resize();
|
||||
viewer.scene.requestRender();
|
||||
if (gridResizeTimer === undefined) {
|
||||
gridResizeTimer = window.setTimeout(() => {
|
||||
gridResizeTimer = undefined;
|
||||
rebuildGrid();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
} catch (error) {
|
||||
@@ -2014,6 +2388,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
resizeObserver?.disconnect();
|
||||
removeGridCameraListener?.();
|
||||
removeGridCameraChangedListener?.();
|
||||
if (gridCameraChangedTimer !== undefined) window.clearTimeout(gridCameraChangedTimer);
|
||||
if (gridResizeTimer !== undefined) window.clearTimeout(gridResizeTimer);
|
||||
removeRefreshRenderListener?.();
|
||||
removeRenderErrorListener?.();
|
||||
for (const removeListener of removeProviderFailureListeners) removeListener();
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
MapGatewayHealth,
|
||||
MapPresentation,
|
||||
MapProviderStatus,
|
||||
GridLodProfile,
|
||||
GridSectorSelection,
|
||||
} from "./CesiumMapRenderer.js";
|
||||
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
|
||||
import {
|
||||
@@ -40,6 +42,8 @@ import {
|
||||
} from "./mapReferenceStations.js";
|
||||
import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js";
|
||||
import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs";
|
||||
import { DEFAULT_GRID_LOD_PROFILES, gridLodProfile } from "./mapGridPolicy.mjs";
|
||||
import { MAX_LOCAL_GRID_INDEX } from "./mapSectorGrid.mjs";
|
||||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
@@ -227,12 +231,13 @@ const initialMapSettings: MapPageSettings = {
|
||||
gridLodEnabled: true,
|
||||
grid3dEnabled: true,
|
||||
gridGraticuleEnabled: true,
|
||||
gridCenterMode: "camera",
|
||||
gridCenterMode: "fixed",
|
||||
gridCenterLatitude: 55.7558,
|
||||
gridCenterLongitude: 37.6173,
|
||||
gridTileSizeKm: 10,
|
||||
gridAutoDisableHeightKm: 10_000,
|
||||
gridRebuildOnMoveEnd: true,
|
||||
gridLegacyMode: false,
|
||||
gridMax3dViewAngleDegrees: 30,
|
||||
gridHeightMeters: 500,
|
||||
gridLod1MaxHeightKm: 10,
|
||||
@@ -241,31 +246,47 @@ const initialMapSettings: MapPageSettings = {
|
||||
gridLod2MaxHeightKm: 50,
|
||||
gridLod2StepKm: 5,
|
||||
gridLod2Mode: "3d",
|
||||
gridLod3MaxHeightKm: 180,
|
||||
gridLod3MaxHeightKm: 200,
|
||||
gridLod3StepKm: 25,
|
||||
gridLod3Mode: "3d",
|
||||
gridLod4MaxHeightKm: 700,
|
||||
gridLod4StepKm: 100,
|
||||
gridLod4MaxHeightKm: 800,
|
||||
gridLod4StepKm: 50,
|
||||
gridLod4Mode: "graticule",
|
||||
gridLod5StepKm: 500,
|
||||
gridLod5MaxHeightKm: 3_000,
|
||||
gridLod5StepKm: 50,
|
||||
gridLod5Mode: "graticule",
|
||||
gridRadiusKm: 1_000,
|
||||
gridLineWidth: 4,
|
||||
gridLineDiameterMeters: 10,
|
||||
gridColor: "#f5f5f5",
|
||||
gridRadiusKm: 40,
|
||||
gridLineWidth: 1,
|
||||
gridLineDiameterMeters: 7,
|
||||
gridColor: "#9c9c9c",
|
||||
gridOpacity: 12,
|
||||
gridDotsEnabled: true,
|
||||
gridDotsSize: 7,
|
||||
gridDotsDiameterMeters: 80,
|
||||
gridDotsColor: "#ffffff",
|
||||
gridDotsDiameterMeters: 10,
|
||||
gridDotsColor: "#9c9c9c",
|
||||
gridDotsOpacity: 58,
|
||||
gridCrossesEnabled: false,
|
||||
gridCrossesLengthMeters: 200,
|
||||
gridCrossesLengthMeters: 60,
|
||||
gridCrossesWidthMeters: 10,
|
||||
gridCrossesColor: "#35cfff",
|
||||
gridCrossesOpacity: 50,
|
||||
gridCrossesColor: "#9c9c9c",
|
||||
gridCrossesOpacity: 46,
|
||||
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[],
|
||||
};
|
||||
|
||||
function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): GridLodProfile[] {
|
||||
// A layout saved by the previous flat contract must not lose the values the
|
||||
// operator already tuned. Promote its common visual fields and per-band
|
||||
// height/step/mode values into five authoritative profiles on first read;
|
||||
// the next ordinary page save persists the canonical array.
|
||||
const legacySettings: MapPresentation = {
|
||||
...initialMapSettings,
|
||||
...settings,
|
||||
gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [],
|
||||
cacheRefresh: false,
|
||||
};
|
||||
return Array.from({ length: 5 }, (_unused, index) => gridLodProfile(legacySettings, index) as GridLodProfile);
|
||||
}
|
||||
|
||||
// A valid, deterministic scene view is available before Cesium emits its
|
||||
// first move-end event. It makes the page contract immediately saveable;
|
||||
// the renderer replaces it with the exact live camera as soon as it is ready.
|
||||
@@ -389,6 +410,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
|
||||
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [selectedGridSector, setSelectedGridSector] = useState<GridSectorSelection | null>(null);
|
||||
const [subjectCardOpen, setSubjectCardOpen] = useState(false);
|
||||
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
|
||||
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
|
||||
@@ -419,7 +441,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
// Layouts saved before the cache policy field existed retain the safe
|
||||
// append-only default when they are opened again.
|
||||
cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true,
|
||||
// Camera-relative layouts were decorative and had no stable sector
|
||||
// identity. Opening one performs a deterministic migration to its stored
|
||||
// Moscow origin; the current viewport is never promoted to definition.
|
||||
gridCenterMode: "fixed",
|
||||
gridLodProfiles: resolveGridLodProfiles(initialLayout?.settings),
|
||||
}));
|
||||
const [selectedGridLod, setSelectedGridLod] = useState("0");
|
||||
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
|
||||
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
|
||||
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
|
||||
@@ -646,6 +674,19 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
[cacheRefresh, mapSettings],
|
||||
);
|
||||
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
|
||||
const selectedGridLodIndex = Math.max(0, Math.min(4, Number.parseInt(selectedGridLod, 10) || 0));
|
||||
const activeGridLod = mapSettings.gridLodProfiles[selectedGridLodIndex] ?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex];
|
||||
const minimumGridLodHeight = selectedGridLodIndex === 0
|
||||
? 0.1
|
||||
: mapSettings.gridLodProfiles[selectedGridLodIndex - 1].maxHeightKm + 0.1;
|
||||
const maximumGridLodHeight = selectedGridLodIndex === mapSettings.gridLodProfiles.length - 1
|
||||
? 20_000
|
||||
: Math.max(minimumGridLodHeight, mapSettings.gridLodProfiles[selectedGridLodIndex + 1].maxHeightKm - 0.1);
|
||||
const updateGridLod = (patch: Partial<GridLodProfile>) => updateMapSettings({
|
||||
gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => (
|
||||
index === selectedGridLodIndex ? { ...profile, ...patch } : profile
|
||||
)),
|
||||
});
|
||||
const setCacheEnabled = (cacheEnabled: boolean) => {
|
||||
updateMapSettings({ cacheEnabled });
|
||||
setRendererRevision((value) => value + 1);
|
||||
@@ -1267,57 +1308,47 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
description: "first adapter control",
|
||||
group: "Слои",
|
||||
content: <>
|
||||
<small className="catalog-map-inspector__note">Пять LOD сохраняют пространственную сетку вблизи и переходят к гратикуле на дальних высотах. Новый слой подготавливается до удаления предыдущего.</small>
|
||||
<small className="catalog-map-inspector__note">Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область.</small>
|
||||
<Checker checked={mapSettings.gridVisible} label="Сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
||||
<Checker checked={mapSettings.grid3dEnabled} label="3D-сетка" onChange={(grid3dEnabled) => updateMapSettings({ grid3dEnabled })} />
|
||||
<Checker checked={mapSettings.gridGraticuleEnabled} label="Гратикула" onChange={(gridGraticuleEnabled) => updateMapSettings({ gridGraticuleEnabled })} />
|
||||
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
|
||||
<Checker checked={mapSettings.gridRebuildOnMoveEnd} label="Перестраивать после движения" onChange={(gridRebuildOnMoveEnd) => updateMapSettings({ gridRebuildOnMoveEnd })} />
|
||||
<InspectorSelectField
|
||||
label="Центр сетки"
|
||||
value={mapSettings.gridCenterMode}
|
||||
options={[
|
||||
{ value: "camera", label: "За камерой", description: "Центр следует за viewport по стабильным тайлам" },
|
||||
{ value: "fixed", label: "Фиксированный", description: "Используются заданные координаты" },
|
||||
]}
|
||||
onChange={(gridCenterMode) => updateMapSettings({ gridCenterMode })}
|
||||
/>
|
||||
{mapSettings.gridCenterMode === "fixed" ? <>
|
||||
<RangeControl label="Центр: широта" value={mapSettings.gridCenterLatitude} min={-89.999} max={89.999} step={0.0001} formatValue={(value) => value.toFixed(4)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
|
||||
<RangeControl label="Центр: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.0001} formatValue={(value) => value.toFixed(4)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
|
||||
</> : null}
|
||||
<RangeControl label="Размер тайла" value={mapSettings.gridTileSizeKm} min={1} max={500} step={1} formatValue={(value) => `${value} км`} onChange={(gridTileSizeKm) => updateMapSettings({ gridTileSizeKm })} />
|
||||
<ControlRow label="Система координат"><strong>Fixed ENU · WGS84</strong></ControlRow>
|
||||
<RangeControl label="Origin: широта" value={mapSettings.gridCenterLatitude} min={-89.9} max={89.9} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
|
||||
<RangeControl label="Origin: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
|
||||
<RangeControl label="Автовыключение выше" value={mapSettings.gridAutoDisableHeightKm} min={0} max={50_000} step={100} formatValue={(value) => value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} />
|
||||
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} />
|
||||
<RangeControl label="Макс. угол обзора 3D" value={mapSettings.gridMax3dViewAngleDegrees} min={0} max={89} step={1} formatValue={(value) => `${value}°`} onChange={(gridMax3dViewAngleDegrees) => updateMapSettings({ gridMax3dViewAngleDegrees })} />
|
||||
<InspectorSelectField label="LOD 1: режим" value={mapSettings.gridLod1Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod1Mode) => updateMapSettings({ gridLod1Mode })} />
|
||||
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} />
|
||||
<RangeControl label="LOD 1: шаг" value={mapSettings.gridLod1StepKm} min={1} max={10} formatValue={(value) => `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} />
|
||||
<InspectorSelectField label="LOD 2: режим" value={mapSettings.gridLod2Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod2Mode) => updateMapSettings({ gridLod2Mode })} />
|
||||
<RangeControl label="LOD 2: до высоты" value={mapSettings.gridLod2MaxHeightKm} min={10} max={200} formatValue={(value) => `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} />
|
||||
<RangeControl label="LOD 2: шаг" value={mapSettings.gridLod2StepKm} min={1} max={25} formatValue={(value) => `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} />
|
||||
<InspectorSelectField label="LOD 3: режим" value={mapSettings.gridLod3Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod3Mode) => updateMapSettings({ gridLod3Mode })} />
|
||||
<RangeControl label="LOD 3: до высоты" value={mapSettings.gridLod3MaxHeightKm} min={50} max={1_000} step={10} formatValue={(value) => `${value} км`} onChange={(gridLod3MaxHeightKm) => updateMapSettings({ gridLod3MaxHeightKm })} />
|
||||
<RangeControl label="LOD 3: шаг" value={mapSettings.gridLod3StepKm} min={5} max={100} formatValue={(value) => `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} />
|
||||
<InspectorSelectField label="LOD 4: режим" value={mapSettings.gridLod4Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod4Mode) => updateMapSettings({ gridLod4Mode })} />
|
||||
<RangeControl label="LOD 4: до высоты" value={mapSettings.gridLod4MaxHeightKm} min={100} max={5_000} step={50} formatValue={(value) => `${value} км`} onChange={(gridLod4MaxHeightKm) => updateMapSettings({ gridLod4MaxHeightKm })} />
|
||||
<RangeControl label="LOD 4: шаг" value={mapSettings.gridLod4StepKm} min={10} max={500} step={5} formatValue={(value) => `${value} км`} onChange={(gridLod4StepKm) => updateMapSettings({ gridLod4StepKm })} />
|
||||
<InspectorSelectField label="LOD 5: режим" value={mapSettings.gridLod5Mode} options={GRID_MODE_OPTIONS} onChange={(gridLod5Mode) => updateMapSettings({ gridLod5Mode })} />
|
||||
<RangeControl label="LOD 5: шаг" value={mapSettings.gridLod5StepKm} min={50} max={2_000} step={25} formatValue={(value) => `${value} км`} onChange={(gridLod5StepKm) => updateMapSettings({ gridLod5StepKm })} />
|
||||
<RangeControl label="Радиус видимости" value={mapSettings.gridRadiusKm} min={5} max={2_000} step={5} formatValue={(value) => `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} />
|
||||
<ControlRow label="Цвет линий"><ColorField label="Цвет линий сетки" value={mapSettings.gridColor} onChange={(gridColor) => updateMapSettings({ gridColor })} /></ControlRow>
|
||||
<RangeControl label="3D-линии: диаметр" value={mapSettings.gridLineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(gridLineDiameterMeters) => updateMapSettings({ gridLineDiameterMeters })} />
|
||||
<RangeControl label="Гратикула: толщина" value={mapSettings.gridLineWidth} min={1} max={8} formatValue={(value) => `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} />
|
||||
<RangeControl label="Прозрачность сетки" value={mapSettings.gridOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} />
|
||||
<Checker checked={mapSettings.gridDotsEnabled} label="Кружки" onChange={(gridDotsEnabled) => updateMapSettings({ gridDotsEnabled })} />
|
||||
<RangeControl label="Кружки: диаметр" value={mapSettings.gridDotsDiameterMeters} min={2} max={1_000} step={2} formatValue={(value) => `${value} м`} onChange={(gridDotsDiameterMeters) => updateMapSettings({ gridDotsDiameterMeters })} />
|
||||
<ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={mapSettings.gridDotsColor} onChange={(gridDotsColor) => updateMapSettings({ gridDotsColor })} /></ControlRow>
|
||||
<RangeControl label="Кружки: прозрачность" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} />
|
||||
<Checker checked={mapSettings.gridCrossesEnabled} label="Кресты" onChange={(gridCrossesEnabled) => updateMapSettings({ gridCrossesEnabled })} />
|
||||
<RangeControl label="Кресты: длина" value={mapSettings.gridCrossesLengthMeters} min={2} max={2_000} step={2} formatValue={(value) => `${value} м`} onChange={(gridCrossesLengthMeters) => updateMapSettings({ gridCrossesLengthMeters })} />
|
||||
<RangeControl label="Кресты: ширина" value={mapSettings.gridCrossesWidthMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(gridCrossesWidthMeters) => updateMapSettings({ gridCrossesWidthMeters })} />
|
||||
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={mapSettings.gridCrossesColor} onChange={(gridCrossesColor) => updateMapSettings({ gridCrossesColor })} /></ControlRow>
|
||||
<RangeControl label="Кресты: прозрачность" value={mapSettings.gridCrossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridCrossesOpacity) => updateMapSettings({ gridCrossesOpacity })} />
|
||||
<div className="catalog-map-grid-lod-tabs">
|
||||
<SegmentedControl value={selectedGridLod} items={DEFAULT_GRID_LOD_PROFILES.map((_profile, index) => ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} />
|
||||
</div>
|
||||
<RangeControl label={selectedGridLodIndex === 4 ? "Порог профиля" : "До высоты"} value={activeGridLod.maxHeightKm} min={minimumGridLodHeight} max={maximumGridLodHeight} step={0.1} formatValue={(value) => `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} />
|
||||
{selectedGridLodIndex === 4 ? <small className="catalog-map-inspector__note">Последний LOD остаётся активным выше своего порога до общего автовыключения.</small> : null}
|
||||
<InspectorSelectField label="Режим" value={activeGridLod.mode} options={GRID_MODE_OPTIONS} onChange={(mode) => updateGridLod({ mode })} />
|
||||
<RangeControl label="Высота WGS84" value={activeGridLod.heightMeters} min={0} max={5_000} step={10} formatValue={(value) => `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} />
|
||||
<RangeControl label="Конус видимости 3D" value={activeGridLod.max3dViewAngleDegrees} min={30} max={170} step={1} formatValue={(value) => `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} />
|
||||
<RangeControl label="Шаг ENU-секторов" value={activeGridLod.stepKm} min={0.1} max={5_000} step={0.1} formatValue={(value) => `${value} км`} onChange={(stepKm) => updateGridLod({ stepKm, radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX) })} />
|
||||
<RangeControl label="Размер тайла ENU" value={activeGridLod.tileSizeKm} min={1} max={50} step={1} formatValue={(value) => `${value} км`} onChange={(tileSizeKm) => updateGridLod({ tileSizeKm })} />
|
||||
<RangeControl label="Радиус ENU-поля" value={activeGridLod.radiusKm} min={1} max={Math.min(100_000, activeGridLod.stepKm * MAX_LOCAL_GRID_INDEX)} step={1} formatValue={(value) => `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} />
|
||||
<RangeControl label="Диаметр 3D-линий" value={activeGridLod.lineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} />
|
||||
<ControlRow label="Цвет 3D-линий"><ColorField label="Цвет линий ENU-сетки" value={activeGridLod.lineColor} onChange={(lineColor) => updateGridLod({ lineColor })} /></ControlRow>
|
||||
<RangeControl label="Прозрачность 3D-линий" value={activeGridLod.lineOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} />
|
||||
<Checker checked={activeGridLod.dotsEnabled} label="Кружки" onChange={(dotsEnabled) => updateGridLod({ dotsEnabled })} />
|
||||
<RangeControl label="Кружки: диаметр" value={activeGridLod.dotsDiameterMeters} min={1} max={1_000} step={1} formatValue={(value) => `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} />
|
||||
<ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={activeGridLod.dotsColor} onChange={(dotsColor) => updateGridLod({ dotsColor })} /></ControlRow>
|
||||
<RangeControl label="Кружки: прозрачность" value={activeGridLod.dotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} />
|
||||
<Checker checked={activeGridLod.crossesEnabled} label="Кресты" onChange={(crossesEnabled) => updateGridLod({ crossesEnabled })} />
|
||||
<RangeControl label="Кресты: длина" value={activeGridLod.crossesLengthMeters} min={2} max={5_000} step={2} formatValue={(value) => `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} />
|
||||
<RangeControl label="Кресты: ширина" value={activeGridLod.crossesWidthMeters} min={1} max={500} step={1} formatValue={(value) => `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} />
|
||||
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={activeGridLod.crossesColor} onChange={(crossesColor) => updateGridLod({ crossesColor })} /></ControlRow>
|
||||
<RangeControl label="Кресты: прозрачность" value={activeGridLod.crossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} />
|
||||
<RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees })} />
|
||||
<RangeControl label="Толщина гратикулы" value={activeGridLod.graticuleLineWidthPx} min={1} max={3} step={1} formatValue={(value) => `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} />
|
||||
<ControlRow label="Цвет гратикулы"><ColorField label="Цвет WGS84-гратику́лы" value={activeGridLod.graticuleColor} onChange={(graticuleColor) => updateGridLod({ graticuleColor })} /></ControlRow>
|
||||
<RangeControl label="Прозрачность гратикулы" value={activeGridLod.graticuleOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} />
|
||||
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
|
||||
{selectedGridSector ? <small className="catalog-map-inspector__note">LOD {selectedGridSector.lod} · {selectedGridSector.units === "meters-enu"
|
||||
? `ENU ${selectedGridSector.bounds.west}…${selectedGridSector.bounds.east} м E; ${selectedGridSector.bounds.south}…${selectedGridSector.bounds.north} м N`
|
||||
: `WGS84 ${selectedGridSector.bounds.west}…${selectedGridSector.bounds.east}°; ${selectedGridSector.bounds.south}…${selectedGridSector.bounds.north}°`}</small> : null}
|
||||
</>,
|
||||
},
|
||||
{
|
||||
@@ -1447,6 +1478,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
key={rendererRevision}
|
||||
ref={mapRendererRef}
|
||||
onSelect={handleSelect}
|
||||
onGridSectorSelect={setSelectedGridSector}
|
||||
onGatewayHealth={handleRendererGatewayHealth}
|
||||
onProviderStatus={setProviderStatus}
|
||||
onCameraChange={handleCameraChange}
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import type { MapPresentation } from "./CesiumMapRenderer.js";
|
||||
|
||||
export type GridLodMode = "3d" | "graticule";
|
||||
export type GridLodBand = {
|
||||
index: number;
|
||||
id: string;
|
||||
export type GridLodProfile = {
|
||||
maxHeightKm: number;
|
||||
stepKm: number;
|
||||
mode: GridLodMode;
|
||||
heightMeters: number;
|
||||
max3dViewAngleDegrees: number;
|
||||
tileSizeKm: number;
|
||||
radiusKm: number;
|
||||
lineDiameterMeters: number;
|
||||
lineColor: string;
|
||||
lineOpacity: number;
|
||||
dotsEnabled: boolean;
|
||||
dotsDiameterMeters: number;
|
||||
dotsColor: string;
|
||||
dotsOpacity: number;
|
||||
crossesEnabled: boolean;
|
||||
crossesLengthMeters: number;
|
||||
crossesWidthMeters: number;
|
||||
crossesColor: string;
|
||||
crossesOpacity: number;
|
||||
graticuleStepDegrees: number;
|
||||
graticuleLineWidthPx: number;
|
||||
graticuleColor: string;
|
||||
graticuleOpacity: number;
|
||||
};
|
||||
export type GridLodBand = GridLodProfile & {
|
||||
index: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const GRID_LOD_HYSTERESIS_RATIO: number;
|
||||
export const GRID_LOD_COUNT: number;
|
||||
export const DEFAULT_GRID_LOD_PROFILES: readonly Readonly<GridLodProfile>[];
|
||||
export function gridLodProfile(settings: MapPresentation, index: number): GridLodProfile;
|
||||
export function gridLodBands(settings: MapPresentation): GridLodBand[];
|
||||
export function selectGridLod(
|
||||
settings: MapPresentation,
|
||||
@@ -19,10 +44,10 @@ export function selectGridLod(
|
||||
export function resolveGridMode(
|
||||
settings: MapPresentation,
|
||||
requestedMode: GridLodMode,
|
||||
viewAngleFromNadirDegrees: number,
|
||||
): GridLodMode | "hidden";
|
||||
export function gridShouldBeVisible(settings: MapPresentation, cameraHeightKm: number): boolean;
|
||||
export function snapGridCenter(
|
||||
center: { latitude: number; longitude: number },
|
||||
settings: MapPresentation,
|
||||
tileSizeKm?: number,
|
||||
): { latitude: number; longitude: number };
|
||||
|
||||
@@ -1,37 +1,95 @@
|
||||
const GRID_LOD_COUNT = 5;
|
||||
import { fixedGridOrigin } from "./mapSectorGrid.mjs";
|
||||
|
||||
export const GRID_LOD_COUNT = 5;
|
||||
export const GRID_LOD_HYSTERESIS_RATIO = 0.08;
|
||||
|
||||
const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
|
||||
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
|
||||
|
||||
// Exact effective values of the tracked Engine MMAP/MOSCOWMAP workflow. The
|
||||
// renderer fixes donor defects (camera-relative phase, geodesic parallels and
|
||||
// blank swaps) but preserves the tuned distance and visual profile.
|
||||
export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
|
||||
Object.freeze({
|
||||
maxHeightKm: 10, stepKm: 1, mode: "3d", heightMeters: 300, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 50, lineDiameterMeters: 4, lineColor: "#f5f5f5", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 7, dotsColor: "#ffffff", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 1_000, lineDiameterMeters: 10, lineColor: "#f8fbfc", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 80, dotsColor: "#ffffff", dotsOpacity: 22,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 25, radiusKm: 1_000, lineDiameterMeters: 7, lineColor: "#fafafa", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#ffffff", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 1_000, lineDiameterMeters: 10, lineColor: "#fcfdfd", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#fcfdfd", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
// Engine keeps the last LOD selected above this threshold; the independent
|
||||
// 10,000 km auto-disable remains the actual upper visibility boundary.
|
||||
maxHeightKm: 3_000, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 100, lineDiameterMeters: 7, lineColor: "#9c9c9c", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#9c9c9c", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8,
|
||||
}),
|
||||
]);
|
||||
|
||||
const color = (value, fallback) => typeof value === "string" ? value : fallback;
|
||||
|
||||
export function gridLodProfile(settings, index) {
|
||||
const fallback = DEFAULT_GRID_LOD_PROFILES[index] ?? DEFAULT_GRID_LOD_PROFILES[0];
|
||||
const source = Array.isArray(settings.gridLodProfiles) && settings.gridLodProfiles[index]
|
||||
? settings.gridLodProfiles[index]
|
||||
: {};
|
||||
const number = index + 1;
|
||||
return {
|
||||
maxHeightKm: finite(source.maxHeightKm, finite(settings[`gridLod${number}MaxHeightKm`], fallback.maxHeightKm)),
|
||||
stepKm: clamp(finite(source.stepKm, finite(settings[`gridLod${number}StepKm`], fallback.stepKm)), 0.1, 5_000),
|
||||
mode: (source.mode ?? settings[`gridLod${number}Mode`] ?? fallback.mode) === "graticule" ? "graticule" : "3d",
|
||||
heightMeters: clamp(finite(source.heightMeters, finite(settings.gridHeightMeters, fallback.heightMeters)), 0, 5_000),
|
||||
max3dViewAngleDegrees: clamp(finite(source.max3dViewAngleDegrees, finite(settings.gridMax3dViewAngleDegrees, fallback.max3dViewAngleDegrees)), 30, 170),
|
||||
tileSizeKm: clamp(finite(source.tileSizeKm, finite(settings.gridTileSizeKm, fallback.tileSizeKm)), 1, 50),
|
||||
radiusKm: clamp(finite(source.radiusKm, finite(settings.gridRadiusKm, fallback.radiusKm)), 1, 100_000),
|
||||
lineDiameterMeters: clamp(finite(source.lineDiameterMeters, finite(settings.gridLineDiameterMeters, fallback.lineDiameterMeters)), 1, 100),
|
||||
lineColor: color(source.lineColor, color(settings.gridColor, fallback.lineColor)),
|
||||
lineOpacity: clamp(finite(source.lineOpacity, finite(settings.gridOpacity, fallback.lineOpacity)), 0, 100),
|
||||
dotsEnabled: source.dotsEnabled ?? settings.gridDotsEnabled ?? fallback.dotsEnabled,
|
||||
dotsDiameterMeters: clamp(finite(source.dotsDiameterMeters, finite(settings.gridDotsDiameterMeters, fallback.dotsDiameterMeters)), 1, 1_000),
|
||||
dotsColor: color(source.dotsColor, color(settings.gridDotsColor, fallback.dotsColor)),
|
||||
dotsOpacity: clamp(finite(source.dotsOpacity, finite(settings.gridDotsOpacity, fallback.dotsOpacity)), 0, 100),
|
||||
crossesEnabled: source.crossesEnabled ?? settings.gridCrossesEnabled ?? fallback.crossesEnabled,
|
||||
crossesLengthMeters: clamp(finite(source.crossesLengthMeters, finite(settings.gridCrossesLengthMeters, fallback.crossesLengthMeters)), 1, 5_000),
|
||||
crossesWidthMeters: clamp(finite(source.crossesWidthMeters, finite(settings.gridCrossesWidthMeters, fallback.crossesWidthMeters)), 1, 500),
|
||||
crossesColor: color(source.crossesColor, color(settings.gridCrossesColor, fallback.crossesColor)),
|
||||
crossesOpacity: clamp(finite(source.crossesOpacity, finite(settings.gridCrossesOpacity, fallback.crossesOpacity)), 0, 100),
|
||||
graticuleStepDegrees: clamp(finite(source.graticuleStepDegrees, fallback.graticuleStepDegrees), 0.1, 180),
|
||||
graticuleLineWidthPx: clamp(finite(source.graticuleLineWidthPx ?? source.lineWidthPx, finite(settings.gridLineWidth, fallback.graticuleLineWidthPx)), 1, 3),
|
||||
graticuleColor: color(source.graticuleColor, color(source.lineColor, color(settings.gridColor, fallback.graticuleColor))),
|
||||
graticuleOpacity: clamp(finite(source.graticuleOpacity, finite(source.lineOpacity, finite(settings.gridOpacity, fallback.graticuleOpacity))), 0, 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function gridLodBands(settings) {
|
||||
const maximums = [
|
||||
finite(settings.gridLod1MaxHeightKm, 10),
|
||||
finite(settings.gridLod2MaxHeightKm, 50),
|
||||
finite(settings.gridLod3MaxHeightKm, 180),
|
||||
finite(settings.gridLod4MaxHeightKm, 700),
|
||||
Number.POSITIVE_INFINITY,
|
||||
];
|
||||
for (let index = 1; index < maximums.length - 1; index += 1) {
|
||||
maximums[index] = Math.max(maximums[index - 1], maximums[index]);
|
||||
}
|
||||
const steps = [
|
||||
finite(settings.gridLod1StepKm, 1),
|
||||
finite(settings.gridLod2StepKm, 5),
|
||||
finite(settings.gridLod3StepKm, 25),
|
||||
finite(settings.gridLod4StepKm, 100),
|
||||
finite(settings.gridLod5StepKm, 500),
|
||||
];
|
||||
const modes = [1, 2, 3, 4, 5].map((number) => (
|
||||
settings[`gridLod${number}Mode`] === "graticule" ? "graticule" : "3d"
|
||||
));
|
||||
return maximums.map((maxHeightKm, index) => ({
|
||||
const profiles = Array.from({ length: GRID_LOD_COUNT }, (_unused, index) => gridLodProfile(settings, index));
|
||||
return profiles.map((profile, index) => ({
|
||||
...profile,
|
||||
index,
|
||||
id: `lod-${index + 1}`,
|
||||
maxHeightKm,
|
||||
stepKm: clamp(steps[index], 0.25, 5_000),
|
||||
mode: modes[index],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -39,14 +97,15 @@ export function selectGridLod(settings, cameraHeightKm, previousIndex = null) {
|
||||
const bands = gridLodBands(settings);
|
||||
if (!settings.gridLodEnabled) return bands[0];
|
||||
const height = Math.max(0, finite(cameraHeightKm, 0));
|
||||
const directIndex = Math.max(0, bands.findIndex((band) => height <= band.maxHeightKm));
|
||||
const matchedIndex = bands.findIndex((band) => height <= band.maxHeightKm);
|
||||
const directIndex = matchedIndex === -1 ? bands.length - 1 : matchedIndex;
|
||||
if (!Number.isInteger(previousIndex) || previousIndex < 0 || previousIndex >= GRID_LOD_COUNT) {
|
||||
return bands[directIndex];
|
||||
}
|
||||
|
||||
const previous = bands[previousIndex];
|
||||
const lowerBoundary = previousIndex === 0 ? 0 : bands[previousIndex - 1].maxHeightKm;
|
||||
const upperBoundary = previous.maxHeightKm;
|
||||
const upperBoundary = previousIndex === bands.length - 1 ? Number.POSITIVE_INFINITY : previous.maxHeightKm;
|
||||
const lowerHold = lowerBoundary * (1 - GRID_LOD_HYSTERESIS_RATIO);
|
||||
const upperHold = Number.isFinite(upperBoundary)
|
||||
? upperBoundary * (1 + GRID_LOD_HYSTERESIS_RATIO)
|
||||
@@ -54,10 +113,9 @@ export function selectGridLod(settings, cameraHeightKm, previousIndex = null) {
|
||||
return height >= lowerHold && height <= upperHold ? previous : bands[directIndex];
|
||||
}
|
||||
|
||||
export function resolveGridMode(settings, requestedMode, viewAngleFromNadirDegrees) {
|
||||
export function resolveGridMode(settings, requestedMode) {
|
||||
if (requestedMode === "3d") {
|
||||
const maxAngle = clamp(finite(settings.gridMax3dViewAngleDegrees, 30), 0, 89);
|
||||
if (settings.grid3dEnabled !== false && viewAngleFromNadirDegrees <= maxAngle) return "3d";
|
||||
if (settings.grid3dEnabled !== false) return "3d";
|
||||
return settings.gridGraticuleEnabled === false ? "hidden" : "graticule";
|
||||
}
|
||||
if (settings.gridGraticuleEnabled !== false) return "graticule";
|
||||
@@ -70,21 +128,9 @@ export function gridShouldBeVisible(settings, cameraHeightKm) {
|
||||
return limit <= 0 || Math.max(0, finite(cameraHeightKm, 0)) <= limit;
|
||||
}
|
||||
|
||||
export function snapGridCenter({ latitude, longitude }, settings) {
|
||||
if (settings.gridCenterMode === "fixed") {
|
||||
return {
|
||||
latitude: clamp(finite(settings.gridCenterLatitude, 55.7558), -89.999, 89.999),
|
||||
longitude: clamp(finite(settings.gridCenterLongitude, 37.6173), -180, 180),
|
||||
};
|
||||
}
|
||||
const sourceLatitude = clamp(finite(latitude, 55.7558), -89.999, 89.999);
|
||||
const sourceLongitude = clamp(finite(longitude, 37.6173), -180, 180);
|
||||
const tileKm = clamp(finite(settings.gridTileSizeKm, 10), 0.25, 5_000);
|
||||
const latitudeStep = tileKm / 110.574;
|
||||
const snappedLatitude = Math.round(sourceLatitude / latitudeStep) * latitudeStep;
|
||||
const longitudeStep = tileKm / Math.max(0.001, 111.320 * Math.cos(snappedLatitude * Math.PI / 180));
|
||||
return {
|
||||
latitude: snappedLatitude,
|
||||
longitude: Math.round(sourceLongitude / longitudeStep) * longitudeStep,
|
||||
};
|
||||
// Backward-compatible export. The camera argument is intentionally ignored:
|
||||
// a sector definition has one immutable origin and camera movement only
|
||||
// changes LOD/visibility.
|
||||
export function snapGridCenter(_cameraCenter, settings) {
|
||||
return fixedGridOrigin(settings);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type LocalGridDefinition = {
|
||||
lod: number;
|
||||
originLatitude: number;
|
||||
originLongitude: number;
|
||||
stepMeters: number;
|
||||
};
|
||||
|
||||
export type LocalSectorAddress = {
|
||||
family: "local-enu";
|
||||
lod: number;
|
||||
eastIndex: number;
|
||||
northIndex: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GraticuleDefinition = { lod: number; stepDegrees: number };
|
||||
export type GraticuleSectorAddress = {
|
||||
family: "wgs84-graticule";
|
||||
lod: number;
|
||||
longitudeIndex: number;
|
||||
latitudeIndex: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const MAX_LOCAL_GRID_INDEX: number;
|
||||
|
||||
export function normalizeLongitudeDegrees(value: number): number;
|
||||
export function fixedGridOrigin(settings: { gridCenterLatitude?: number; gridCenterLongitude?: number }): { latitude: number; longitude: number };
|
||||
export function localSectorId(definition: LocalGridDefinition, eastIndex: number, northIndex: number): string;
|
||||
export function localSectorAt(point: { eastMeters: number; northMeters: number }, definition: LocalGridDefinition): LocalSectorAddress;
|
||||
export function localSectorBounds(address: LocalSectorAddress, stepMeters: number): { west: number; east: number; south: number; north: number };
|
||||
export function localSectorNeighbors(address: LocalSectorAddress, definition: LocalGridDefinition): Record<"north" | "east" | "south" | "west", LocalSectorAddress>;
|
||||
export function localParentSector(address: LocalSectorAddress, childStepMeters: number, parentDefinition: LocalGridDefinition): LocalSectorAddress;
|
||||
export function localGridPlan(input: { stepMeters: number; radiusMeters: number; maximumMarkers?: number }): {
|
||||
stepMeters: number;
|
||||
radiusMeters: number;
|
||||
requestedRadiusMeters: number;
|
||||
clipped: boolean;
|
||||
maximumIndex: number;
|
||||
markerStride: number;
|
||||
lines: Array<{ index: number; offsetMeters: number; extentMeters: number }>;
|
||||
};
|
||||
export function graticuleSectorId(definition: GraticuleDefinition, longitudeIndex: number, latitudeIndex: number): string;
|
||||
export function graticuleSectorAt(point: { longitude: number; latitude: number }, definition: GraticuleDefinition): GraticuleSectorAddress;
|
||||
export function graticuleSectorBounds(address: GraticuleSectorAddress, stepDegrees: number): { west: number; east: number; south: number; north: number };
|
||||
export function splitLongitudeRange(west: number, east: number): Array<{ west: number; east: number }>;
|
||||
export function alignedGridValues(minimum: number, maximum: number, step: number, options?: { includeMaximum?: boolean }): number[];
|
||||
export function boundedAngularParts(start: number, end: number, maximumSpanDegrees?: number): Array<{ start: number; end: number }>;
|
||||
export function graticuleGranularity(stepDegrees: number, clampToGround: boolean): number;
|
||||
export function graticuleLinePlan(input: {
|
||||
south: number;
|
||||
north: number;
|
||||
longitudeIntervals: Array<{ west: number; east: number }>;
|
||||
stepDegrees: number;
|
||||
}): {
|
||||
stepDegrees: number;
|
||||
south: number;
|
||||
north: number;
|
||||
longitudeIntervals: Array<{ west: number; east: number }>;
|
||||
parallels: number[];
|
||||
meridians: Array<{ longitude: number; interval: { west: number; east: number } }>;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -958,6 +958,31 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.catalog-map-grid-lod-tabs {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-map-grid-lod-tabs .nodedc-segmented {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-map-grid-lod-tabs .nodedc-segmented__item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-inline: 0.45rem;
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.catalog-map-grid-sector-id {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__tab-panel {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
- Foundry session/auth boundary: `server/nodedc-auth.mjs`;
|
||||
- UI TileCache и health state: `apps/catalog/src/MapFixturePreview.tsx`;
|
||||
- Cesium adapter и provider startup: `apps/catalog/src/CesiumMapRenderer.tsx`;
|
||||
- LOD policy и canonical M-MAP defaults: `apps/catalog/src/mapGridPolicy.mjs`;
|
||||
- чистая адресация геосекторов: `apps/catalog/src/mapSectorGrid.mjs`;
|
||||
- Platform provider/cache boundary: `../../platform/services/map-gateway/src/server.mjs`;
|
||||
- Map Gateway runtime topology: `../../platform/infra/synology/docker-compose.platform-http.yml`;
|
||||
- NAS egress service: `../../platform/services/dc-amd-proxy/server.mjs`;
|
||||
@@ -160,6 +162,34 @@ Per-entity `PolygonGraphics` для массового слоя геозон н
|
||||
цвета обновляет per-instance attributes без перестройки геометрии; изменение
|
||||
толщины границы или cursor создаёт новое bounded batch generation.
|
||||
|
||||
### 4.4 Секторальная геосетка
|
||||
|
||||
Сетка принадлежит Map Page и остаётся provider-neutral. Cesium adapter только
|
||||
материализует рассчитанные линии и выполняет аналитический pick сектора.
|
||||
Положение камеры никогда не становится origin или фазой сетки.
|
||||
|
||||
- один фиксированный WGS84 origin хранится в layout (`55.7558`, `37.6173` по
|
||||
умолчанию); старый `camera` mode при чтении мигрирует в `fixed`;
|
||||
- LOD 1–3 используют метрические индексы ENU от origin, абсолютную WGS84
|
||||
высоту и `clampToGround=false`; terrain и 3D Tiles не меняют границы;
|
||||
- LOD 4–5 используют отдельный угловой шаг WGS84 с глобальной нулевой фазой;
|
||||
`stepKm` не участвует в гратикуле;
|
||||
- M-MAP thresholds: `10 / 50 / 200 / 800 / 3000 км`; последний LOD остаётся
|
||||
активным до независимого auto-off `10000 км`;
|
||||
- режимы по умолчанию: `3D / 3D / 3D / graticule / graticule`;
|
||||
- параллели строятся как линии постоянной широты (`RHUMB` с явной выборкой),
|
||||
а viewport через антимеридиан разбивается на два диапазона;
|
||||
- sector id включает origin, LOD, шаг и signed integer address. Он не содержит
|
||||
render serial, viewport или camera state и сохраняется при pan/zoom/reload;
|
||||
- camera может менять LOD, view-cone и набор видимых линий, но не координаты,
|
||||
границы или идентификаторы секторов;
|
||||
- новый buffer подключается до удаления предыдущего. Дешёвый plan key
|
||||
сравнивается до создания Cesium objects; markers имеют bounded stride.
|
||||
|
||||
Изменение origin или шага означает новую sector definition и закономерно
|
||||
меняет идентификаторы. Сохранённые sector ids нельзя молча интерпретировать с
|
||||
другим layout profile.
|
||||
|
||||
## 5. Credential model
|
||||
|
||||
### 5.1 Cesium Ion master token
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
||||
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
||||
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
||||
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs",
|
||||
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs",
|
||||
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
||||
"test:map-inspector-overlay-state": "node --test scripts/map-inspector-overlay-state.test.mjs",
|
||||
"test:map-reference-stations": "node --test scripts/map-reference-stations.test.mjs",
|
||||
|
||||
@@ -35,12 +35,13 @@
|
||||
"gridLodEnabled": true,
|
||||
"grid3dEnabled": true,
|
||||
"gridGraticuleEnabled": true,
|
||||
"gridCenterMode": "camera",
|
||||
"gridCenterMode": "fixed",
|
||||
"gridCenterLatitude": 55.7558,
|
||||
"gridCenterLongitude": 37.6173,
|
||||
"gridTileSizeKm": 10,
|
||||
"gridAutoDisableHeightKm": 10000,
|
||||
"gridRebuildOnMoveEnd": true,
|
||||
"gridLegacyMode": false,
|
||||
"gridMax3dViewAngleDegrees": 30,
|
||||
"gridHeightMeters": 500,
|
||||
"gridLod1MaxHeightKm": 10,
|
||||
@@ -49,29 +50,37 @@
|
||||
"gridLod2MaxHeightKm": 50,
|
||||
"gridLod2StepKm": 5,
|
||||
"gridLod2Mode": "3d",
|
||||
"gridLod3MaxHeightKm": 180,
|
||||
"gridLod3MaxHeightKm": 200,
|
||||
"gridLod3StepKm": 25,
|
||||
"gridLod3Mode": "3d",
|
||||
"gridLod4MaxHeightKm": 700,
|
||||
"gridLod4StepKm": 100,
|
||||
"gridLod4MaxHeightKm": 800,
|
||||
"gridLod4StepKm": 50,
|
||||
"gridLod4Mode": "graticule",
|
||||
"gridLod5StepKm": 500,
|
||||
"gridLod5MaxHeightKm": 3000,
|
||||
"gridLod5StepKm": 50,
|
||||
"gridLod5Mode": "graticule",
|
||||
"gridRadiusKm": 1000,
|
||||
"gridLineWidth": 4,
|
||||
"gridLineDiameterMeters": 10,
|
||||
"gridColor": "#f5f5f5",
|
||||
"gridRadiusKm": 40,
|
||||
"gridLineWidth": 1,
|
||||
"gridLineDiameterMeters": 7,
|
||||
"gridColor": "#9c9c9c",
|
||||
"gridOpacity": 12,
|
||||
"gridDotsEnabled": true,
|
||||
"gridDotsSize": 7,
|
||||
"gridDotsDiameterMeters": 80,
|
||||
"gridDotsColor": "#ffffff",
|
||||
"gridDotsDiameterMeters": 10,
|
||||
"gridDotsColor": "#9c9c9c",
|
||||
"gridDotsOpacity": 58,
|
||||
"gridCrossesEnabled": false,
|
||||
"gridCrossesLengthMeters": 200,
|
||||
"gridCrossesLengthMeters": 60,
|
||||
"gridCrossesWidthMeters": 10,
|
||||
"gridCrossesColor": "#35cfff",
|
||||
"gridCrossesOpacity": 50
|
||||
"gridCrossesColor": "#9c9c9c",
|
||||
"gridCrossesOpacity": 46,
|
||||
"gridLodProfiles": [
|
||||
{ "maxHeightKm": 10, "stepKm": 1, "mode": "3d", "heightMeters": 300, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 50, "lineDiameterMeters": 4, "lineColor": "#f5f5f5", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 7, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.25, "graticuleLineWidthPx": 1, "graticuleColor": "#f5f5f5", "graticuleOpacity": 12 },
|
||||
{ "maxHeightKm": 50, "stepKm": 5, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#f8fbfc", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 80, "dotsColor": "#ffffff", "dotsOpacity": 22, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.5, "graticuleLineWidthPx": 1, "graticuleColor": "#f8fbfc", "graticuleOpacity": 12 },
|
||||
{ "maxHeightKm": 200, "stepKm": 25, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 25, "radiusKm": 1000, "lineDiameterMeters": 7, "lineColor": "#fafafa", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 1, "graticuleLineWidthPx": 1, "graticuleColor": "#fafafa", "graticuleOpacity": 12 },
|
||||
{ "maxHeightKm": 800, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#fcfdfd", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#fcfdfd", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#fcfdfd", "graticuleOpacity": 12 },
|
||||
{ "maxHeightKm": 3000, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 100, "lineDiameterMeters": 7, "lineColor": "#9c9c9c", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#9c9c9c", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#9c9c9c", "graticuleOpacity": 8 }
|
||||
]
|
||||
},
|
||||
"mapHeight": 620,
|
||||
"camera": {
|
||||
|
||||
+229
-36
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DEFAULT_GRID_LOD_PROFILES,
|
||||
gridLodProfile,
|
||||
gridShouldBeVisible,
|
||||
resolveGridMode,
|
||||
selectGridLod,
|
||||
@@ -11,68 +13,259 @@ import {
|
||||
const settings = {
|
||||
gridVisible: true,
|
||||
gridLodEnabled: true,
|
||||
gridLod1MaxHeightKm: 10,
|
||||
gridLod1StepKm: 1,
|
||||
gridLod1Mode: "3d",
|
||||
gridLod2MaxHeightKm: 50,
|
||||
gridLod2StepKm: 5,
|
||||
gridLod2Mode: "3d",
|
||||
gridLod3MaxHeightKm: 180,
|
||||
gridLod3StepKm: 25,
|
||||
gridLod3Mode: "3d",
|
||||
gridLod4MaxHeightKm: 700,
|
||||
gridLod4StepKm: 100,
|
||||
gridLod4Mode: "graticule",
|
||||
gridLod5StepKm: 500,
|
||||
gridLod5Mode: "graticule",
|
||||
grid3dEnabled: true,
|
||||
gridGraticuleEnabled: true,
|
||||
gridMax3dViewAngleDegrees: 30,
|
||||
gridAutoDisableHeightKm: 10_000,
|
||||
gridCenterMode: "camera",
|
||||
gridTileSizeKm: 10,
|
||||
gridCenterMode: "fixed",
|
||||
gridCenterLatitude: 55.7558,
|
||||
gridCenterLongitude: 37.6173,
|
||||
gridLodProfiles: DEFAULT_GRID_LOD_PROFILES,
|
||||
};
|
||||
|
||||
test("five grid LODs preserve the close 3D and distant graticule contract", () => {
|
||||
test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD donor profile", () => {
|
||||
assert.deepEqual(DEFAULT_GRID_LOD_PROFILES, [
|
||||
{
|
||||
maxHeightKm: 10,
|
||||
stepKm: 1,
|
||||
mode: "3d",
|
||||
heightMeters: 300,
|
||||
max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10,
|
||||
radiusKm: 50,
|
||||
lineDiameterMeters: 4,
|
||||
lineColor: "#f5f5f5",
|
||||
lineOpacity: 12,
|
||||
dotsEnabled: true,
|
||||
dotsDiameterMeters: 7,
|
||||
dotsColor: "#ffffff",
|
||||
dotsOpacity: 58,
|
||||
crossesEnabled: false,
|
||||
crossesLengthMeters: 60,
|
||||
crossesWidthMeters: 10,
|
||||
crossesColor: "#9c9c9c",
|
||||
crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.25,
|
||||
graticuleLineWidthPx: 1,
|
||||
graticuleColor: "#f5f5f5",
|
||||
graticuleOpacity: 12,
|
||||
},
|
||||
{
|
||||
maxHeightKm: 50,
|
||||
stepKm: 5,
|
||||
mode: "3d",
|
||||
heightMeters: 500,
|
||||
max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10,
|
||||
radiusKm: 1_000,
|
||||
lineDiameterMeters: 10,
|
||||
lineColor: "#f8fbfc",
|
||||
lineOpacity: 12,
|
||||
dotsEnabled: true,
|
||||
dotsDiameterMeters: 80,
|
||||
dotsColor: "#ffffff",
|
||||
dotsOpacity: 22,
|
||||
crossesEnabled: false,
|
||||
crossesLengthMeters: 60,
|
||||
crossesWidthMeters: 10,
|
||||
crossesColor: "#9c9c9c",
|
||||
crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.5,
|
||||
graticuleLineWidthPx: 1,
|
||||
graticuleColor: "#f8fbfc",
|
||||
graticuleOpacity: 12,
|
||||
},
|
||||
{
|
||||
maxHeightKm: 200,
|
||||
stepKm: 25,
|
||||
mode: "3d",
|
||||
heightMeters: 500,
|
||||
max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 25,
|
||||
radiusKm: 1_000,
|
||||
lineDiameterMeters: 7,
|
||||
lineColor: "#fafafa",
|
||||
lineOpacity: 12,
|
||||
dotsEnabled: true,
|
||||
dotsDiameterMeters: 10,
|
||||
dotsColor: "#ffffff",
|
||||
dotsOpacity: 58,
|
||||
crossesEnabled: false,
|
||||
crossesLengthMeters: 60,
|
||||
crossesWidthMeters: 10,
|
||||
crossesColor: "#9c9c9c",
|
||||
crossesOpacity: 46,
|
||||
graticuleStepDegrees: 1,
|
||||
graticuleLineWidthPx: 1,
|
||||
graticuleColor: "#fafafa",
|
||||
graticuleOpacity: 12,
|
||||
},
|
||||
{
|
||||
maxHeightKm: 800,
|
||||
stepKm: 50,
|
||||
mode: "graticule",
|
||||
heightMeters: 500,
|
||||
max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10,
|
||||
radiusKm: 1_000,
|
||||
lineDiameterMeters: 10,
|
||||
lineColor: "#fcfdfd",
|
||||
lineOpacity: 12,
|
||||
dotsEnabled: true,
|
||||
dotsDiameterMeters: 10,
|
||||
dotsColor: "#fcfdfd",
|
||||
dotsOpacity: 58,
|
||||
crossesEnabled: false,
|
||||
crossesLengthMeters: 60,
|
||||
crossesWidthMeters: 10,
|
||||
crossesColor: "#9c9c9c",
|
||||
crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2,
|
||||
graticuleLineWidthPx: 1,
|
||||
graticuleColor: "#fcfdfd",
|
||||
graticuleOpacity: 12,
|
||||
},
|
||||
{
|
||||
maxHeightKm: 3_000,
|
||||
stepKm: 50,
|
||||
mode: "graticule",
|
||||
heightMeters: 500,
|
||||
max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10,
|
||||
radiusKm: 100,
|
||||
lineDiameterMeters: 7,
|
||||
lineColor: "#9c9c9c",
|
||||
lineOpacity: 12,
|
||||
dotsEnabled: true,
|
||||
dotsDiameterMeters: 10,
|
||||
dotsColor: "#9c9c9c",
|
||||
dotsOpacity: 58,
|
||||
crossesEnabled: false,
|
||||
crossesLengthMeters: 60,
|
||||
crossesWidthMeters: 10,
|
||||
crossesColor: "#9c9c9c",
|
||||
crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2,
|
||||
graticuleLineWidthPx: 1,
|
||||
graticuleColor: "#9c9c9c",
|
||||
graticuleOpacity: 8,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("LOD selection keeps three spatial bands, two graticule bands and the independent 10,000 km cutoff", () => {
|
||||
assert.deepEqual(
|
||||
[5, 40, 120, 500, 2_000].map((height) => {
|
||||
[0, 10, 10.001, 50, 50.001, 200, 200.001, 800, 800.001, 3_000, 9_999].map((height) => {
|
||||
const band = selectGridLod(settings, height);
|
||||
return [band.id, band.stepKm, band.mode];
|
||||
}),
|
||||
[
|
||||
["lod-1", 1, "3d"],
|
||||
["lod-1", 1, "3d"],
|
||||
["lod-2", 5, "3d"],
|
||||
["lod-2", 5, "3d"],
|
||||
["lod-3", 25, "3d"],
|
||||
["lod-4", 100, "graticule"],
|
||||
["lod-5", 500, "graticule"],
|
||||
["lod-3", 25, "3d"],
|
||||
["lod-4", 50, "graticule"],
|
||||
["lod-4", 50, "graticule"],
|
||||
["lod-5", 50, "graticule"],
|
||||
["lod-5", 50, "graticule"],
|
||||
["lod-5", 50, "graticule"],
|
||||
],
|
||||
);
|
||||
assert.equal(gridShouldBeVisible(settings, 10_000), true);
|
||||
assert.equal(gridShouldBeVisible(settings, 10_000.001), false);
|
||||
});
|
||||
|
||||
test("LOD hysteresis holds the previous band around a threshold", () => {
|
||||
assert.equal(selectGridLod(settings, 10.5, 0).id, "lod-1");
|
||||
assert.equal(selectGridLod(settings, 10.9, 0).id, "lod-2");
|
||||
assert.equal(selectGridLod(settings, 9.5, 1).id, "lod-2");
|
||||
assert.equal(selectGridLod(settings, 9.1, 1).id, "lod-1");
|
||||
test("every LOD owns independent metric and angular spacing and visual fields", () => {
|
||||
const profiles = DEFAULT_GRID_LOD_PROFILES.map((profile, index) => ({
|
||||
...profile,
|
||||
stepKm: 11 + index,
|
||||
lineDiameterMeters: 21 + index,
|
||||
graticuleStepDegrees: 0.125 * (index + 1),
|
||||
graticuleLineWidthPx: 1 + (index % 3),
|
||||
graticuleColor: `#00000${index}`,
|
||||
graticuleOpacity: 30 + index,
|
||||
}));
|
||||
const profile = gridLodProfile({ ...settings, gridLodProfiles: profiles }, 3);
|
||||
assert.equal(profile.stepKm, 14);
|
||||
assert.equal(profile.lineDiameterMeters, 24);
|
||||
assert.equal(profile.graticuleStepDegrees, 0.5);
|
||||
assert.equal(profile.graticuleLineWidthPx, 1);
|
||||
assert.equal(profile.graticuleColor, "#000003");
|
||||
assert.equal(profile.graticuleOpacity, 33);
|
||||
});
|
||||
|
||||
test("oblique close view falls back to the graticule without dropping the grid", () => {
|
||||
assert.equal(resolveGridMode(settings, "3d", 12), "3d");
|
||||
assert.equal(resolveGridMode(settings, "3d", 48), "graticule");
|
||||
assert.equal(resolveGridMode({ ...settings, gridGraticuleEnabled: false }, "3d", 48), "hidden");
|
||||
test("legacy flat visuals migrate while angular graticule spacing stays independent", () => {
|
||||
const profile = gridLodProfile({
|
||||
...settings,
|
||||
gridLodProfiles: undefined,
|
||||
gridRadiusKm: 1_234,
|
||||
gridHeightMeters: 640,
|
||||
gridLineDiameterMeters: 17,
|
||||
gridColor: "#abcdef",
|
||||
gridOpacity: 37,
|
||||
gridDotsEnabled: false,
|
||||
gridLod2StepKm: 7,
|
||||
}, 1);
|
||||
assert.equal(profile.maxHeightKm, 50);
|
||||
assert.equal(profile.stepKm, 7);
|
||||
assert.equal(profile.radiusKm, 1_234);
|
||||
assert.equal(profile.heightMeters, 640);
|
||||
assert.equal(profile.lineDiameterMeters, 17);
|
||||
assert.equal(profile.lineColor, "#abcdef");
|
||||
assert.equal(profile.lineOpacity, 37);
|
||||
assert.equal(profile.dotsEnabled, false);
|
||||
assert.equal(profile.graticuleStepDegrees, 0.5);
|
||||
assert.equal(profile.graticuleLineWidthPx, 1);
|
||||
assert.equal(profile.graticuleColor, "#abcdef");
|
||||
assert.equal(profile.graticuleOpacity, 37);
|
||||
});
|
||||
|
||||
test("grid auto-disable and stable camera tile center are deterministic", () => {
|
||||
assert.equal(gridShouldBeVisible(settings, 9_999), true);
|
||||
assert.equal(gridShouldBeVisible(settings, 10_001), false);
|
||||
test("LOD hysteresis holds the previous band for eight percent on either side of a threshold", () => {
|
||||
assert.equal(selectGridLod(settings, 10.8, 0).id, "lod-1");
|
||||
assert.equal(selectGridLod(settings, 10.8001, 0).id, "lod-2");
|
||||
assert.equal(selectGridLod(settings, 9.2001, 1).id, "lod-2");
|
||||
assert.equal(selectGridLod(settings, 9.1999, 1).id, "lod-1");
|
||||
assert.equal(selectGridLod(settings, 864, 3).id, "lod-4");
|
||||
assert.equal(selectGridLod(settings, 864.001, 3).id, "lod-5");
|
||||
});
|
||||
|
||||
test("mode fallback preserves a usable grid when one renderer is disabled", () => {
|
||||
assert.equal(resolveGridMode(settings, "3d"), "3d");
|
||||
assert.equal(resolveGridMode(settings, "graticule"), "graticule");
|
||||
assert.equal(resolveGridMode({ ...settings, grid3dEnabled: false }, "3d"), "graticule");
|
||||
assert.equal(resolveGridMode({ ...settings, gridGraticuleEnabled: false }, "graticule"), "3d");
|
||||
assert.equal(resolveGridMode({ ...settings, grid3dEnabled: false, gridGraticuleEnabled: false }, "3d"), "hidden");
|
||||
});
|
||||
|
||||
test("grid center is fixed and camera-independent, including for legacy camera-mode payloads", () => {
|
||||
const first = snapGridCenter({ latitude: 55.7558, longitude: 37.6173 }, settings);
|
||||
const second = snapGridCenter({ latitude: 55.76, longitude: 37.62 }, settings);
|
||||
assert.deepEqual(first, second);
|
||||
const second = snapGridCenter({ latitude: -22, longitude: -140 }, settings);
|
||||
assert.deepEqual(first, { latitude: 55.7558, longitude: 37.6173 });
|
||||
assert.deepEqual(second, first);
|
||||
assert.deepEqual(snapGridCenter({ latitude: 0, longitude: 0 }, {
|
||||
...settings,
|
||||
gridCenterMode: "camera",
|
||||
gridCenterLatitude: 12.5,
|
||||
gridCenterLongitude: 190,
|
||||
}), { latitude: 12.5, longitude: -170 });
|
||||
});
|
||||
|
||||
test("renderer swaps double-buffered data sources and never clears the live grid first", async () => {
|
||||
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
|
||||
const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /class GridLayerController/);
|
||||
assert.match(source, /dataSourceDisplay\.ready/);
|
||||
assert.match(source, /fixedGridOrigin\(presentation\)/);
|
||||
assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
||||
assert.match(source, /localSectorAt\(/);
|
||||
assert.match(source, /gridController\?\.pick\(worldPosition\)/);
|
||||
assert.match(source, /lod\.stepKm \* 1_000/);
|
||||
assert.match(source, /lod\.graticuleStepDegrees/);
|
||||
assert.match(source, /lod\.graticuleLineWidthPx/);
|
||||
assert.match(source, /arcType: ArcType\.RHUMB/);
|
||||
assert.match(source, /clampToGround: false/);
|
||||
assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
||||
assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
||||
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
||||
assert.doesNotMatch(source, /snapGridCenter\(/);
|
||||
});
|
||||
|
||||
@@ -70,10 +70,7 @@ test("facet tree selection focuses the subject without resetting a compatible de
|
||||
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
||||
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
|
||||
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
|
||||
assert.match(renderer, /camera\.pickEllipsoid/);
|
||||
assert.match(renderer, /Cartesian3\.subtract\(camera\.position, viewportCenter/);
|
||||
assert.match(renderer, /heading: camera\.heading/);
|
||||
assert.match(renderer, /pitch: camera\.pitch/);
|
||||
assert.match(renderer, /roll: camera\.roll/);
|
||||
assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
|
||||
assert.match(renderer, /duration: 0\.45/);
|
||||
assert.match(renderer, /offset: new HeadingPitchRange\(0, -0\.9, 8_000\)/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
ArcType,
|
||||
ApproximateTerrainHeights,
|
||||
Cartesian3,
|
||||
GroundPolylineGeometry,
|
||||
Math as CesiumMath,
|
||||
PolylineGeometry,
|
||||
} from "cesium";
|
||||
import {
|
||||
alignedGridValues,
|
||||
boundedAngularParts,
|
||||
fixedGridOrigin,
|
||||
graticuleGranularity,
|
||||
graticuleLinePlan,
|
||||
graticuleSectorAt,
|
||||
graticuleSectorBounds,
|
||||
localGridPlan,
|
||||
MAX_LOCAL_GRID_INDEX,
|
||||
localParentSector,
|
||||
localSectorAt,
|
||||
localSectorBounds,
|
||||
localSectorNeighbors,
|
||||
normalizeLongitudeDegrees,
|
||||
splitLongitudeRange,
|
||||
} from "../apps/catalog/src/mapSectorGrid.mjs";
|
||||
|
||||
const localDefinition = {
|
||||
lod: 1,
|
||||
originLatitude: 55.7558,
|
||||
originLongitude: 37.6173,
|
||||
stepMeters: 1_000,
|
||||
};
|
||||
|
||||
test("fixed origin normalizes WGS84 coordinates without consulting the camera", () => {
|
||||
assert.deepEqual(fixedGridOrigin({}), { latitude: 55.7558, longitude: 37.6173 });
|
||||
assert.deepEqual(fixedGridOrigin({ gridCenterLatitude: 100, gridCenterLongitude: 540 }), {
|
||||
latitude: 89.9,
|
||||
longitude: -180,
|
||||
});
|
||||
assert.equal(normalizeLongitudeDegrees(-540), -180);
|
||||
assert.equal(normalizeLongitudeDegrees(360), 0);
|
||||
assert.equal(Object.is(normalizeLongitudeDegrees(-360), -0), false);
|
||||
});
|
||||
|
||||
test("local sectors use half-open floor boundaries on both sides of the ENU origin", () => {
|
||||
assert.deepEqual(
|
||||
[
|
||||
[0, 0],
|
||||
[999.999, 999.999],
|
||||
[1_000, 1_000],
|
||||
[-0.001, -0.001],
|
||||
[-1_000, -1_000],
|
||||
[-1_000.001, -1_000.001],
|
||||
].map(([eastMeters, northMeters]) => {
|
||||
const sector = localSectorAt({ eastMeters, northMeters }, localDefinition);
|
||||
return [sector.eastIndex, sector.northIndex];
|
||||
}),
|
||||
[
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
[-1, -1],
|
||||
[-1, -1],
|
||||
[-2, -2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("local sector IDs and bounds stay stable across calls and normalized equivalent origins", () => {
|
||||
const first = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition);
|
||||
const second = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, {
|
||||
...localDefinition,
|
||||
originLongitude: localDefinition.originLongitude + 360,
|
||||
});
|
||||
assert.equal(first.id, "grid/local/55.755800,37.617300/l1/s1000.000/e-1/n+2");
|
||||
assert.equal(second.id, first.id);
|
||||
assert.deepEqual(localSectorBounds(first, localDefinition.stepMeters), {
|
||||
west: -1_000,
|
||||
east: 0,
|
||||
south: 2_000,
|
||||
north: 3_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("local sector neighbors and integer-ratio parents preserve signed addressing", () => {
|
||||
const child = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition);
|
||||
const neighbors = localSectorNeighbors(child, localDefinition);
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(neighbors).map(([direction, address]) => [
|
||||
direction,
|
||||
[address.eastIndex, address.northIndex],
|
||||
])),
|
||||
{
|
||||
north: [-1, 3],
|
||||
east: [0, 2],
|
||||
south: [-1, 1],
|
||||
west: [-2, 2],
|
||||
},
|
||||
);
|
||||
const parent = localParentSector(child, 1_000, { ...localDefinition, lod: 2, stepMeters: 5_000 });
|
||||
assert.deepEqual([parent.eastIndex, parent.northIndex], [-1, 0]);
|
||||
assert.equal(parent.id, "grid/local/55.755800,37.617300/l2/s5000.000/e-1/n+0");
|
||||
assert.throws(
|
||||
() => localParentSector(child, 1_000, { ...localDefinition, lod: 2, stepMeters: 2_500 }),
|
||||
/grid_parent_step_must_be_integer_multiple/,
|
||||
);
|
||||
});
|
||||
|
||||
test("local grid plans are symmetric about the immutable origin and cap marker density", () => {
|
||||
const plan = localGridPlan({ stepMeters: 1_000, radiusMeters: 2_000, maximumMarkers: 4 });
|
||||
assert.deepEqual(plan.lines.map(({ index, offsetMeters }) => [index, offsetMeters]), [
|
||||
[-2, -2_000],
|
||||
[-1, -1_000],
|
||||
[0, 0],
|
||||
[1, 1_000],
|
||||
[2, 2_000],
|
||||
]);
|
||||
assert.equal(plan.lines[0].extentMeters, 0);
|
||||
assert.equal(plan.lines[2].extentMeters, 2_000);
|
||||
assert.equal(plan.markerStride, 2);
|
||||
const bounded = localGridPlan({ stepMeters: 100, radiusMeters: 100_000_000 });
|
||||
assert.equal(bounded.maximumIndex, MAX_LOCAL_GRID_INDEX);
|
||||
assert.equal(bounded.clipped, true);
|
||||
assert.ok(bounded.lines.length <= MAX_LOCAL_GRID_INDEX * 2 + 1);
|
||||
});
|
||||
|
||||
test("bounded RHUMB parts compile through Cesium at the equator, poles and date line", () => {
|
||||
const compile = (positions) => PolylineGeometry.createGeometry(new PolylineGeometry({
|
||||
positions,
|
||||
width: 1,
|
||||
arcType: ArcType.RHUMB,
|
||||
granularity: CesiumMath.toRadians(2),
|
||||
}));
|
||||
for (const part of boundedAngularParts(-180, 180)) {
|
||||
assert.ok(compile([
|
||||
Cartesian3.fromDegrees(part.start, 0, 500),
|
||||
Cartesian3.fromDegrees(part.end, 0, 500),
|
||||
]));
|
||||
}
|
||||
for (const part of boundedAngularParts(-89.9, 89.9)) {
|
||||
assert.ok(compile([
|
||||
Cartesian3.fromDegrees(180, part.start, 500),
|
||||
Cartesian3.fromDegrees(180, part.end, 500),
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
test("ground graticule uses metre granularity and compiles without explosive subdivision", () => {
|
||||
const stepDegrees = 2;
|
||||
const granularity = graticuleGranularity(stepDegrees, true);
|
||||
assert.ok(granularity > 200_000 && granularity < 225_000);
|
||||
assert.equal(graticuleGranularity(stepDegrees, false), CesiumMath.toRadians(stepDegrees));
|
||||
|
||||
const positions = boundedAngularParts(-180, 180)
|
||||
.reduce((values, part, index) => [
|
||||
...values,
|
||||
...(index === 0 ? [part.start] : []),
|
||||
part.end,
|
||||
], [])
|
||||
.map((longitude) => Cartesian3.fromDegrees(longitude, 0));
|
||||
const previousTerrainHeights = ApproximateTerrainHeights._terrainHeights;
|
||||
try {
|
||||
ApproximateTerrainHeights._terrainHeights = {};
|
||||
const source = new GroundPolylineGeometry({
|
||||
positions,
|
||||
width: 1,
|
||||
arcType: ArcType.RHUMB,
|
||||
granularity,
|
||||
});
|
||||
assert.equal(source.granularity, granularity);
|
||||
assert.ok(GroundPolylineGeometry.createGeometry(source));
|
||||
} finally {
|
||||
ApproximateTerrainHeights._terrainHeights = previousTerrainHeights;
|
||||
}
|
||||
});
|
||||
|
||||
test("anti-meridian ranges split explicitly while whole-world ranges keep one interval", () => {
|
||||
assert.deepEqual(splitLongitudeRange(170, -170), [
|
||||
{ west: 170, east: 180 },
|
||||
{ west: -180, east: -170 },
|
||||
]);
|
||||
assert.deepEqual(splitLongitudeRange(-170, 170), [{ west: -170, east: 170 }]);
|
||||
assert.deepEqual(splitLongitudeRange(-180, 180), [{ west: -180, east: 180 }]);
|
||||
assert.deepEqual(splitLongitudeRange(10, 370), [{ west: -180, east: 180 }]);
|
||||
});
|
||||
|
||||
test("graticule lines retain a global zero phase and never duplicate the date-line meridian", () => {
|
||||
const longitudeIntervals = splitLongitudeRange(170, -170);
|
||||
const plan = graticuleLinePlan({
|
||||
south: -3.7,
|
||||
north: 3.7,
|
||||
longitudeIntervals,
|
||||
stepDegrees: 2,
|
||||
});
|
||||
assert.deepEqual(plan.parallels, [-2, 0, 2]);
|
||||
assert.deepEqual(plan.meridians.map(({ longitude }) => longitude), [
|
||||
170, 172, 174, 176, 178,
|
||||
-180, -178, -176, -174, -172, -170,
|
||||
]);
|
||||
assert.equal(new Set(plan.meridians.map(({ longitude }) => longitude)).size, plan.meridians.length);
|
||||
assert.deepEqual(alignedGridValues(1, 7, 2), [2, 4, 6]);
|
||||
|
||||
const world = graticuleLinePlan({
|
||||
south: -2,
|
||||
north: 2,
|
||||
longitudeIntervals: [{ west: -180, east: 180 }],
|
||||
stepDegrees: 2,
|
||||
});
|
||||
assert.equal(world.meridians.some(({ longitude }) => longitude === 180), false);
|
||||
assert.equal(world.meridians.filter(({ longitude }) => longitude === -180).length, 1);
|
||||
});
|
||||
|
||||
test("graticule sector IDs, negative boundaries and bounds use the same global phase", () => {
|
||||
const definition = { lod: 4, stepDegrees: 2 };
|
||||
const negative = graticuleSectorAt({ longitude: -0.0001, latitude: -0.0001 }, definition);
|
||||
const zero = graticuleSectorAt({ longitude: 0, latitude: 0 }, definition);
|
||||
const seamWest = graticuleSectorAt({ longitude: -180, latitude: 0 }, definition);
|
||||
const seamEast = graticuleSectorAt({ longitude: 180, latitude: 0 }, definition);
|
||||
assert.deepEqual([negative.longitudeIndex, negative.latitudeIndex], [-1, -1]);
|
||||
assert.equal(negative.id, "grid/wgs84/l4/s2.000000/x-1/y-1");
|
||||
assert.equal(zero.id, "grid/wgs84/l4/s2.000000/x+0/y+0");
|
||||
assert.deepEqual(graticuleSectorBounds(negative, definition.stepDegrees), {
|
||||
west: -2,
|
||||
east: 0,
|
||||
south: -2,
|
||||
north: 0,
|
||||
});
|
||||
assert.equal(seamEast.id, seamWest.id);
|
||||
});
|
||||
+191
-21
@@ -520,21 +520,183 @@ const MAP_PAGE_SETTING_KEYS = new Set([
|
||||
"buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast",
|
||||
"imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters",
|
||||
"grid3dEnabled", "gridGraticuleEnabled", "gridCenterMode", "gridCenterLatitude",
|
||||
"gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridRebuildOnMoveEnd",
|
||||
"gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridRebuildOnMoveEnd", "gridLegacyMode",
|
||||
"gridMax3dViewAngleDegrees",
|
||||
"gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm",
|
||||
"gridLod3MaxHeightKm", "gridLod3StepKm", "gridLod4MaxHeightKm", "gridLod4StepKm",
|
||||
"gridLod5StepKm", "gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode",
|
||||
"gridLod5MaxHeightKm", "gridLod5StepKm", "gridLod1Mode", "gridLod2Mode", "gridLod3Mode", "gridLod4Mode",
|
||||
"gridLod5Mode", "gridRadiusKm", "gridLineWidth", "gridLineDiameterMeters", "gridColor", "gridOpacity",
|
||||
"gridDotsEnabled", "gridDotsSize", "gridDotsDiameterMeters", "gridDotsColor", "gridDotsOpacity",
|
||||
"gridCrossesEnabled", "gridCrossesLengthMeters", "gridCrossesWidthMeters",
|
||||
"gridCrossesColor", "gridCrossesOpacity",
|
||||
"gridCrossesColor", "gridCrossesOpacity", "gridLodProfiles",
|
||||
]);
|
||||
|
||||
const GRID_LOD_PROFILE_KEYS = new Set([
|
||||
"maxHeightKm", "stepKm", "mode", "heightMeters", "max3dViewAngleDegrees", "tileSizeKm", "radiusKm",
|
||||
"lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters",
|
||||
"dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters",
|
||||
"crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor",
|
||||
"graticuleOpacity",
|
||||
]);
|
||||
|
||||
const GRID_LOD_PROFILE_INPUT_KEYS = new Set([...GRID_LOD_PROFILE_KEYS, "lineWidthPx"]);
|
||||
const MAX_LOCAL_GRID_INDEX = 512;
|
||||
const LEGACY_GRID_PROFILE_SETTING_KEYS = new Set([
|
||||
"gridHeightMeters", "gridTileSizeKm", "gridMax3dViewAngleDegrees", "gridRadiusKm",
|
||||
"gridLineWidth", "gridLineDiameterMeters", "gridColor", "gridOpacity",
|
||||
"gridDotsEnabled", "gridDotsSize", "gridDotsDiameterMeters", "gridDotsColor", "gridDotsOpacity",
|
||||
"gridCrossesEnabled", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "gridCrossesColor", "gridCrossesOpacity",
|
||||
...Array.from({ length: 5 }, (_unused, index) => index + 1).flatMap((number) => [
|
||||
`gridLod${number}MaxHeightKm`, `gridLod${number}StepKm`, `gridLod${number}Mode`,
|
||||
]),
|
||||
]);
|
||||
|
||||
// Exact effective values of the tracked Engine MMAP/MOSCOWMAP Cesium grid.
|
||||
// These are persisted by Foundry as presentation policy; the renderer remains
|
||||
// a provider adapter and does not own or silently retune the sector definition.
|
||||
const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
|
||||
Object.freeze({
|
||||
maxHeightKm: 10, stepKm: 1, mode: "3d", heightMeters: 300, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 50, lineDiameterMeters: 4, lineColor: "#f5f5f5", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 7, dotsColor: "#ffffff", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 1_000, lineDiameterMeters: 10, lineColor: "#f8fbfc", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 80, dotsColor: "#ffffff", dotsOpacity: 22,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 25, radiusKm: 1_000, lineDiameterMeters: 7, lineColor: "#fafafa", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#ffffff", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 1_000, lineDiameterMeters: 10, lineColor: "#fcfdfd", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#fcfdfd", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 3_000, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
tileSizeKm: 10, radiusKm: 100, lineDiameterMeters: 7, lineColor: "#9c9c9c", lineOpacity: 12,
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#9c9c9c", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8,
|
||||
}),
|
||||
]);
|
||||
|
||||
function promoteLegacyGridLodProfiles(settings) {
|
||||
return DEFAULT_GRID_LOD_PROFILES.map((fallback, index) => {
|
||||
const number = index + 1;
|
||||
return {
|
||||
...fallback,
|
||||
maxHeightKm: settings[`gridLod${number}MaxHeightKm`] ?? fallback.maxHeightKm,
|
||||
stepKm: settings[`gridLod${number}StepKm`] ?? fallback.stepKm,
|
||||
mode: settings[`gridLod${number}Mode`] ?? fallback.mode,
|
||||
heightMeters: settings.gridHeightMeters ?? fallback.heightMeters,
|
||||
max3dViewAngleDegrees: settings.gridMax3dViewAngleDegrees ?? fallback.max3dViewAngleDegrees,
|
||||
tileSizeKm: settings.gridTileSizeKm ?? fallback.tileSizeKm,
|
||||
radiusKm: settings.gridRadiusKm ?? fallback.radiusKm,
|
||||
lineDiameterMeters: settings.gridLineDiameterMeters ?? fallback.lineDiameterMeters,
|
||||
lineColor: settings.gridColor ?? fallback.lineColor,
|
||||
lineOpacity: settings.gridOpacity ?? fallback.lineOpacity,
|
||||
dotsEnabled: settings.gridDotsEnabled ?? fallback.dotsEnabled,
|
||||
dotsDiameterMeters: settings.gridDotsDiameterMeters ?? fallback.dotsDiameterMeters,
|
||||
dotsColor: settings.gridDotsColor ?? fallback.dotsColor,
|
||||
dotsOpacity: settings.gridDotsOpacity ?? fallback.dotsOpacity,
|
||||
crossesEnabled: settings.gridCrossesEnabled ?? fallback.crossesEnabled,
|
||||
crossesLengthMeters: settings.gridCrossesLengthMeters ?? fallback.crossesLengthMeters,
|
||||
crossesWidthMeters: settings.gridCrossesWidthMeters ?? fallback.crossesWidthMeters,
|
||||
crossesColor: settings.gridCrossesColor ?? fallback.crossesColor,
|
||||
crossesOpacity: settings.gridCrossesOpacity ?? fallback.crossesOpacity,
|
||||
graticuleLineWidthPx: settings.gridLineWidth ?? fallback.graticuleLineWidthPx,
|
||||
graticuleColor: settings.gridColor ?? fallback.graticuleColor,
|
||||
graticuleOpacity: settings.gridOpacity ?? fallback.graticuleOpacity,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateGridLodProfiles(value) {
|
||||
if (!Array.isArray(value) || value.length !== 5) throw applicationError("invalid_map_grid_lod_profiles");
|
||||
let previousMaxHeightKm = Number.NEGATIVE_INFINITY;
|
||||
return value.map((profile, index) => {
|
||||
if (!isObject(profile) || Object.keys(profile).some((key) => !GRID_LOD_PROFILE_INPUT_KEYS.has(key))) {
|
||||
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`);
|
||||
}
|
||||
const legacyProfile = Object.hasOwn(profile, "lineWidthPx");
|
||||
const normalized = legacyProfile ? {
|
||||
...profile,
|
||||
graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees,
|
||||
graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx,
|
||||
graticuleColor: profile.graticuleColor ?? profile.lineColor,
|
||||
graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity,
|
||||
} : { ...profile };
|
||||
delete normalized.lineWidthPx;
|
||||
if (GRID_LOD_PROFILE_KEYS.size !== Object.keys(normalized).length
|
||||
|| [...GRID_LOD_PROFILE_KEYS].some((key) => !Object.hasOwn(normalized, key))) {
|
||||
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`);
|
||||
}
|
||||
const code = (key) => `invalid_map_grid_lod_profile_${index + 1}_${key}`;
|
||||
const maxHeightKm = requireNumber(normalized.maxHeightKm, 0.1, 100_000, code("maxHeightKm"));
|
||||
if (maxHeightKm <= previousMaxHeightKm) {
|
||||
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_maxHeightKm_order`);
|
||||
}
|
||||
previousMaxHeightKm = maxHeightKm;
|
||||
if (!['3d', 'graticule'].includes(normalized.mode)) throw applicationError(code("mode"));
|
||||
const stepKm = requireNumber(normalized.stepKm, 0.1, 5_000, code("stepKm"));
|
||||
const radiusKm = requireNumber(normalized.radiusKm, 1, 100_000, code("radiusKm"));
|
||||
if (radiusKm / stepKm > MAX_LOCAL_GRID_INDEX) {
|
||||
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_workload`);
|
||||
}
|
||||
return {
|
||||
maxHeightKm,
|
||||
stepKm,
|
||||
mode: normalized.mode,
|
||||
heightMeters: requireNumber(normalized.heightMeters, 0, 5_000, code("heightMeters")),
|
||||
max3dViewAngleDegrees: requireNumber(normalized.max3dViewAngleDegrees, 30, 170, code("max3dViewAngleDegrees")),
|
||||
tileSizeKm: requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm")),
|
||||
radiusKm,
|
||||
lineDiameterMeters: requireNumber(normalized.lineDiameterMeters, 1, 100, code("lineDiameterMeters")),
|
||||
lineColor: requireHex(normalized.lineColor, code("lineColor")),
|
||||
lineOpacity: requireNumber(normalized.lineOpacity, 0, 100, code("lineOpacity")),
|
||||
dotsEnabled: requireBoolean(normalized.dotsEnabled, code("dotsEnabled")),
|
||||
dotsDiameterMeters: requireNumber(normalized.dotsDiameterMeters, 1, 1_000, code("dotsDiameterMeters")),
|
||||
dotsColor: requireHex(normalized.dotsColor, code("dotsColor")),
|
||||
dotsOpacity: requireNumber(normalized.dotsOpacity, 0, 100, code("dotsOpacity")),
|
||||
crossesEnabled: requireBoolean(normalized.crossesEnabled, code("crossesEnabled")),
|
||||
crossesLengthMeters: requireNumber(normalized.crossesLengthMeters, 1, 5_000, code("crossesLengthMeters")),
|
||||
crossesWidthMeters: requireNumber(normalized.crossesWidthMeters, 1, 500, code("crossesWidthMeters")),
|
||||
crossesColor: requireHex(normalized.crossesColor, code("crossesColor")),
|
||||
crossesOpacity: requireNumber(normalized.crossesOpacity, 0, 100, code("crossesOpacity")),
|
||||
graticuleStepDegrees: requireNumber(normalized.graticuleStepDegrees, 0.1, 180, code("graticuleStepDegrees")),
|
||||
graticuleLineWidthPx: requireNumber(normalized.graticuleLineWidthPx, 1, 3, code("graticuleLineWidthPx")),
|
||||
graticuleColor: requireHex(normalized.graticuleColor, code("graticuleColor")),
|
||||
graticuleOpacity: requireNumber(normalized.graticuleOpacity, 0, 100, code("graticuleOpacity")),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateMapPageSettingsPatch(value) {
|
||||
if (!isObject(value) || Object.keys(value).length === 0) throw applicationError("invalid_map_page_settings_patch");
|
||||
if (Object.keys(value).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_settings_patch_field");
|
||||
return value;
|
||||
if (value.gridCenterMode !== undefined && !["camera", "fixed"].includes(value.gridCenterMode)) {
|
||||
throw applicationError("invalid_map_page_setting_gridCenterMode");
|
||||
}
|
||||
if (value.gridLodProfiles === undefined && Object.keys(value).some((key) => LEGACY_GRID_PROFILE_SETTING_KEYS.has(key))) {
|
||||
throw applicationError("map_grid_lod_profiles_required");
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
...(value.gridCenterMode !== undefined ? { gridCenterMode: "fixed" } : {}),
|
||||
...(value.gridLodProfiles !== undefined ? { gridLodProfiles: validateGridLodProfiles(value.gridLodProfiles) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateMapInspectorOpenSections(value) {
|
||||
@@ -565,7 +727,7 @@ function validateMapPageLayout(value) {
|
||||
// Layouts written before this field existed are safely upgraded to the
|
||||
// no-overwrite default during read/save; they are not rejected as damaged.
|
||||
if (settings.cacheNoOverwrite !== undefined) requireBoolean(settings.cacheNoOverwrite, "invalid_map_page_setting_cacheNoOverwrite");
|
||||
for (const key of ["grid3dEnabled", "gridGraticuleEnabled", "gridRebuildOnMoveEnd", "gridCrossesEnabled"]) {
|
||||
for (const key of ["grid3dEnabled", "gridGraticuleEnabled", "gridRebuildOnMoveEnd", "gridLegacyMode", "gridCrossesEnabled"]) {
|
||||
if (settings[key] !== undefined) requireBoolean(settings[key], `invalid_map_page_setting_${key}`);
|
||||
}
|
||||
requireString(settings.imagerySource, "invalid_map_page_imagery_source", 64);
|
||||
@@ -575,9 +737,11 @@ function validateMapPageLayout(value) {
|
||||
for (const key of ["monochromeColor", "globeColor", "backgroundColor", "buildingsColor", "gridColor", "gridDotsColor"]) {
|
||||
requireHex(settings[key], `invalid_map_page_setting_${key}`);
|
||||
}
|
||||
for (const key of ["gridCenterLatitude", "gridCenterLongitude", "gridTileSizeKm", "gridAutoDisableHeightKm", "gridMax3dViewAngleDegrees", "gridLod3MaxHeightKm", "gridLod4MaxHeightKm", "gridLod4StepKm", "gridLod5StepKm", "gridLineDiameterMeters", "gridDotsDiameterMeters", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "gridCrossesOpacity"]) {
|
||||
for (const key of ["gridTileSizeKm", "gridAutoDisableHeightKm", "gridMax3dViewAngleDegrees", "gridLod3MaxHeightKm", "gridLod4MaxHeightKm", "gridLod4StepKm", "gridLod5MaxHeightKm", "gridLod5StepKm", "gridLineDiameterMeters", "gridDotsDiameterMeters", "gridCrossesLengthMeters", "gridCrossesWidthMeters", "gridCrossesOpacity"]) {
|
||||
if (settings[key] !== undefined) requireNumber(settings[key], -100000, 100000, `invalid_map_page_setting_${key}`);
|
||||
}
|
||||
if (settings.gridCenterLatitude !== undefined) requireNumber(settings.gridCenterLatitude, -90, 90, "invalid_map_page_setting_gridCenterLatitude");
|
||||
if (settings.gridCenterLongitude !== undefined) requireNumber(settings.gridCenterLongitude, -180, 180, "invalid_map_page_setting_gridCenterLongitude");
|
||||
if (settings.gridCrossesColor !== undefined) requireHex(settings.gridCrossesColor, "invalid_map_page_setting_gridCrossesColor");
|
||||
if (settings.gridCenterMode !== undefined && !["camera", "fixed"].includes(settings.gridCenterMode)) {
|
||||
throw applicationError("invalid_map_page_setting_gridCenterMode");
|
||||
@@ -587,6 +751,9 @@ function validateMapPageLayout(value) {
|
||||
throw applicationError(`invalid_map_page_setting_${key}`);
|
||||
}
|
||||
}
|
||||
const gridLodProfiles = validateGridLodProfiles(
|
||||
settings.gridLodProfiles ?? promoteLegacyGridLodProfiles(settings),
|
||||
);
|
||||
const camera = value.camera;
|
||||
for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) {
|
||||
requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`);
|
||||
@@ -652,7 +819,7 @@ function validateMapPageLayout(value) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
pageId: "map",
|
||||
settings: { ...settings, cacheNoOverwrite: settings.cacheNoOverwrite ?? true },
|
||||
settings: { ...settings, cacheNoOverwrite: settings.cacheNoOverwrite ?? true, gridCenterMode: "fixed", gridLodProfiles },
|
||||
mapHeight: requireInteger(value.mapHeight, 360, 5000, "invalid_map_page_height"),
|
||||
camera: {
|
||||
longitude: camera.longitude,
|
||||
@@ -736,12 +903,13 @@ function defaultMapPageLayout() {
|
||||
gridLodEnabled: true,
|
||||
grid3dEnabled: true,
|
||||
gridGraticuleEnabled: true,
|
||||
gridCenterMode: "camera",
|
||||
gridCenterMode: "fixed",
|
||||
gridCenterLatitude: 55.7558,
|
||||
gridCenterLongitude: 37.6173,
|
||||
gridTileSizeKm: 10,
|
||||
gridAutoDisableHeightKm: 10000,
|
||||
gridRebuildOnMoveEnd: true,
|
||||
gridLegacyMode: false,
|
||||
gridMax3dViewAngleDegrees: 30,
|
||||
gridHeightMeters: 500,
|
||||
gridLod1MaxHeightKm: 10,
|
||||
@@ -750,29 +918,31 @@ function defaultMapPageLayout() {
|
||||
gridLod2MaxHeightKm: 50,
|
||||
gridLod2StepKm: 5,
|
||||
gridLod2Mode: "3d",
|
||||
gridLod3MaxHeightKm: 180,
|
||||
gridLod3MaxHeightKm: 200,
|
||||
gridLod3StepKm: 25,
|
||||
gridLod3Mode: "3d",
|
||||
gridLod4MaxHeightKm: 700,
|
||||
gridLod4StepKm: 100,
|
||||
gridLod4MaxHeightKm: 800,
|
||||
gridLod4StepKm: 50,
|
||||
gridLod4Mode: "graticule",
|
||||
gridLod5StepKm: 500,
|
||||
gridLod5MaxHeightKm: 3000,
|
||||
gridLod5StepKm: 50,
|
||||
gridLod5Mode: "graticule",
|
||||
gridRadiusKm: 1000,
|
||||
gridLineWidth: 4,
|
||||
gridLineDiameterMeters: 10,
|
||||
gridColor: "#f5f5f5",
|
||||
gridRadiusKm: 40,
|
||||
gridLineWidth: 1,
|
||||
gridLineDiameterMeters: 7,
|
||||
gridColor: "#9c9c9c",
|
||||
gridOpacity: 12,
|
||||
gridDotsEnabled: true,
|
||||
gridDotsSize: 7,
|
||||
gridDotsDiameterMeters: 80,
|
||||
gridDotsColor: "#ffffff",
|
||||
gridDotsDiameterMeters: 10,
|
||||
gridDotsColor: "#9c9c9c",
|
||||
gridDotsOpacity: 58,
|
||||
gridCrossesEnabled: false,
|
||||
gridCrossesLengthMeters: 200,
|
||||
gridCrossesLengthMeters: 60,
|
||||
gridCrossesWidthMeters: 10,
|
||||
gridCrossesColor: "#35cfff",
|
||||
gridCrossesOpacity: 50,
|
||||
gridCrossesColor: "#9c9c9c",
|
||||
gridCrossesOpacity: 46,
|
||||
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES),
|
||||
},
|
||||
mapHeight: 470,
|
||||
camera: {
|
||||
|
||||
+40
-3
@@ -414,12 +414,13 @@ const mapPageSettingsPatchInputSchema = {
|
||||
gridLodEnabled: { type: "boolean" },
|
||||
grid3dEnabled: { type: "boolean" },
|
||||
gridGraticuleEnabled: { type: "boolean" },
|
||||
gridCenterMode: { type: "string", enum: ["camera", "fixed"] },
|
||||
gridCenterLatitude: { type: "number" },
|
||||
gridCenterLongitude: { type: "number" },
|
||||
gridCenterMode: { type: "string", enum: ["fixed"] },
|
||||
gridCenterLatitude: { type: "number", minimum: -90, maximum: 90 },
|
||||
gridCenterLongitude: { type: "number", minimum: -180, maximum: 180 },
|
||||
gridTileSizeKm: { type: "number" },
|
||||
gridAutoDisableHeightKm: { type: "number" },
|
||||
gridRebuildOnMoveEnd: { type: "boolean" },
|
||||
gridLegacyMode: { type: "boolean" },
|
||||
gridMax3dViewAngleDegrees: { type: "number" },
|
||||
gridHeightMeters: { type: "number" },
|
||||
gridLod1MaxHeightKm: { type: "number" },
|
||||
@@ -434,6 +435,7 @@ const mapPageSettingsPatchInputSchema = {
|
||||
gridLod4MaxHeightKm: { type: "number" },
|
||||
gridLod4StepKm: { type: "number" },
|
||||
gridLod4Mode: { type: "string", enum: ["3d", "graticule"] },
|
||||
gridLod5MaxHeightKm: { type: "number" },
|
||||
gridLod5StepKm: { type: "number" },
|
||||
gridLod5Mode: { type: "string", enum: ["3d", "graticule"] },
|
||||
gridRadiusKm: { type: "number" },
|
||||
@@ -451,6 +453,41 @@ const mapPageSettingsPatchInputSchema = {
|
||||
gridCrossesWidthMeters: { type: "number" },
|
||||
gridCrossesColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
gridCrossesOpacity: { type: "number" },
|
||||
gridLodProfiles: {
|
||||
type: "array",
|
||||
minItems: 5,
|
||||
maxItems: 5,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["maxHeightKm", "stepKm", "mode", "heightMeters", "max3dViewAngleDegrees", "tileSizeKm", "radiusKm", "lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters", "dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters", "crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor", "graticuleOpacity"],
|
||||
properties: {
|
||||
maxHeightKm: { type: "number", minimum: 0.1, maximum: 100000, description: "Strictly greater than the previous LOD maxHeightKm; enforced by Foundry." },
|
||||
stepKm: { type: "number", minimum: 0.1, maximum: 5000 },
|
||||
mode: { type: "string", enum: ["3d", "graticule"] },
|
||||
heightMeters: { type: "number", minimum: 0, maximum: 5000 },
|
||||
max3dViewAngleDegrees: { type: "number", minimum: 30, maximum: 170 },
|
||||
tileSizeKm: { type: "number", minimum: 1, maximum: 50 },
|
||||
radiusKm: { type: "number", minimum: 1, maximum: 100000 },
|
||||
lineDiameterMeters: { type: "number", minimum: 1, maximum: 100 },
|
||||
lineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
lineOpacity: { type: "number", minimum: 0, maximum: 100 },
|
||||
dotsEnabled: { type: "boolean" },
|
||||
dotsDiameterMeters: { type: "number", minimum: 1, maximum: 1000 },
|
||||
dotsColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
dotsOpacity: { type: "number", minimum: 0, maximum: 100 },
|
||||
crossesEnabled: { type: "boolean" },
|
||||
crossesLengthMeters: { type: "number", minimum: 1, maximum: 5000 },
|
||||
crossesWidthMeters: { type: "number", minimum: 1, maximum: 500 },
|
||||
crossesColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
crossesOpacity: { type: "number", minimum: 0, maximum: 100 },
|
||||
graticuleStepDegrees: { type: "number", minimum: 0.1, maximum: 180 },
|
||||
graticuleLineWidthPx: { type: "number", minimum: 1, maximum: 3 },
|
||||
graticuleColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
graticuleOpacity: { type: "number", minimum: 0, maximum: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user