feat(map): add seamless five-band grid LOD
This commit is contained in:
@@ -61,6 +61,12 @@ import {
|
||||
type MapPresentationProfile,
|
||||
} from "./mapPresentationProfile.js";
|
||||
import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs";
|
||||
import {
|
||||
gridShouldBeVisible,
|
||||
resolveGridMode,
|
||||
selectGridLod,
|
||||
snapGridCenter,
|
||||
} from "./mapGridPolicy.mjs";
|
||||
|
||||
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
||||
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
||||
@@ -212,20 +218,45 @@ export type MapPresentation = {
|
||||
imagerySaturation: number;
|
||||
gridVisible: boolean;
|
||||
gridLodEnabled: boolean;
|
||||
grid3dEnabled: boolean;
|
||||
gridGraticuleEnabled: boolean;
|
||||
gridCenterMode: "camera" | "fixed";
|
||||
gridCenterLatitude: number;
|
||||
gridCenterLongitude: number;
|
||||
gridTileSizeKm: number;
|
||||
gridAutoDisableHeightKm: number;
|
||||
gridRebuildOnMoveEnd: boolean;
|
||||
gridMax3dViewAngleDegrees: number;
|
||||
gridHeightMeters: number;
|
||||
gridLod1MaxHeightKm: number;
|
||||
gridLod1StepKm: number;
|
||||
gridLod1Mode: "3d" | "graticule";
|
||||
gridLod2MaxHeightKm: number;
|
||||
gridLod2StepKm: number;
|
||||
gridLod2Mode: "3d" | "graticule";
|
||||
gridLod3MaxHeightKm: number;
|
||||
gridLod3StepKm: number;
|
||||
gridLod3Mode: "3d" | "graticule";
|
||||
gridLod4MaxHeightKm: number;
|
||||
gridLod4StepKm: number;
|
||||
gridLod4Mode: "3d" | "graticule";
|
||||
gridLod5StepKm: number;
|
||||
gridLod5Mode: "3d" | "graticule";
|
||||
gridRadiusKm: number;
|
||||
gridLineWidth: number;
|
||||
gridLineDiameterMeters: number;
|
||||
gridColor: string;
|
||||
gridOpacity: number;
|
||||
gridDotsEnabled: boolean;
|
||||
gridDotsSize: number;
|
||||
gridDotsDiameterMeters: number;
|
||||
gridDotsColor: string;
|
||||
gridDotsOpacity: number;
|
||||
gridCrossesEnabled: boolean;
|
||||
gridCrossesLengthMeters: number;
|
||||
gridCrossesWidthMeters: number;
|
||||
gridCrossesColor: string;
|
||||
gridCrossesOpacity: number;
|
||||
cacheRefresh: boolean;
|
||||
};
|
||||
|
||||
@@ -872,23 +903,63 @@ function syncRuntimeDataSources(
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, presentation: MapPresentation) {
|
||||
const entities = dataSource.entities;
|
||||
entities.removeAll();
|
||||
if (!presentation.gridVisible) return;
|
||||
type GridLayerBuild = {
|
||||
dataSource: CustomDataSource | null;
|
||||
key: string;
|
||||
lodIndex: number | null;
|
||||
};
|
||||
|
||||
function buildGridLayer(
|
||||
viewer: Viewer,
|
||||
presentation: MapPresentation,
|
||||
previousLodIndex: number | null,
|
||||
serial: number,
|
||||
): GridLayerBuild {
|
||||
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000);
|
||||
const stepKm = !presentation.gridLodEnabled || cameraHeightKm <= presentation.gridLod1MaxHeightKm
|
||||
? presentation.gridLod1StepKm
|
||||
: cameraHeightKm <= presentation.gridLod2MaxHeightKm
|
||||
? presentation.gridLod2StepKm
|
||||
: presentation.gridLod3StepKm;
|
||||
const safeStepKm = clamp(stepKm, 0.25, 100);
|
||||
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150);
|
||||
const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
|
||||
if (!gridShouldBeVisible(presentation, cameraHeightKm)) {
|
||||
return { dataSource: null, key: "hidden", lodIndex: null };
|
||||
}
|
||||
|
||||
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 latitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244;
|
||||
const longitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423;
|
||||
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;
|
||||
@@ -899,47 +970,167 @@ function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, prese
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) {
|
||||
const nextLatitude = latitude + index * deltaLatitude;
|
||||
const nextLongitude = longitude + index * deltaLongitude;
|
||||
entities.add({
|
||||
polyline: {
|
||||
positions: [
|
||||
Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, elevation),
|
||||
Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, elevation),
|
||||
],
|
||||
width: clamp(presentation.gridLineWidth, 1, 8),
|
||||
material: lineColor,
|
||||
},
|
||||
});
|
||||
entities.add({
|
||||
polyline: {
|
||||
positions: [
|
||||
Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, elevation),
|
||||
Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, elevation),
|
||||
],
|
||||
width: clamp(presentation.gridLineWidth, 1, 8),
|
||||
material: lineColor,
|
||||
},
|
||||
});
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!presentation.gridDotsEnabled) return;
|
||||
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) {
|
||||
entities.add({
|
||||
position: Cartesian3.fromDegrees(longitude + column * deltaLongitude, latitude + row * deltaLatitude, elevation),
|
||||
point: {
|
||||
pixelSize: clamp(presentation.gridDotsSize, 2, 28),
|
||||
color: dotColor,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
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),
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { dataSource, key, lodIndex: lod.index };
|
||||
}
|
||||
|
||||
class GridLayerController {
|
||||
private current: CustomDataSource | null = null;
|
||||
private pending: CustomDataSource | 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;
|
||||
|
||||
constructor(private readonly viewer: Viewer) {}
|
||||
|
||||
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;
|
||||
const epoch = ++this.epoch;
|
||||
this.cancelPending();
|
||||
|
||||
if (!build.dataSource) {
|
||||
if (this.current) this.viewer.dataSources.remove(this.current, true);
|
||||
this.current = null;
|
||||
this.viewer.scene.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = build.dataSource;
|
||||
this.pending = next;
|
||||
void this.viewer.dataSources.add(next);
|
||||
let readyFrames = 0;
|
||||
const commit = () => {
|
||||
if (epoch !== this.epoch || this.pending !== next) return;
|
||||
this.removeReadyListener?.();
|
||||
this.removeReadyListener = null;
|
||||
if (this.fallbackTimer !== null) window.clearTimeout(this.fallbackTimer);
|
||||
this.fallbackTimer = null;
|
||||
const previous = this.current;
|
||||
this.current = next;
|
||||
this.pending = null;
|
||||
if (previous && previous !== next) this.viewer.dataSources.remove(previous, true);
|
||||
this.viewer.scene.requestRender();
|
||||
};
|
||||
this.removeReadyListener = this.viewer.scene.postRender.addEventListener(() => {
|
||||
readyFrames = this.viewer.dataSourceDisplay.ready ? readyFrames + 1 : 0;
|
||||
if (readyFrames >= 2) commit();
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.epoch += 1;
|
||||
this.cancelPending();
|
||||
if (this.current) this.viewer.dataSources.remove(this.current, true);
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
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);
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyPresentation(
|
||||
@@ -1527,7 +1718,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
let viewer: Viewer | undefined;
|
||||
let handler: ScreenSpaceEventHandler | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
let gridController: GridLayerController | undefined;
|
||||
let removeGridCameraListener: (() => void) | undefined;
|
||||
let removeGridCameraChangedListener: (() => void) | undefined;
|
||||
let removeRefreshRenderListener: (() => void) | undefined;
|
||||
let removeRenderErrorListener: (() => void) | undefined;
|
||||
const removeProviderFailureListeners: Array<() => void> = [];
|
||||
@@ -1593,20 +1786,22 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
|
||||
return endpointResponse.json() as Promise<IonAssetEndpoint>;
|
||||
};
|
||||
const gridDataSource = new CustomDataSource("nodedc-map-grid");
|
||||
viewer.dataSources.add(gridDataSource);
|
||||
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
|
||||
viewer.terrainProvider = terrain.ellipsoid;
|
||||
viewer.scene.globe.depthTestAgainstTerrain = true;
|
||||
viewerRef.current = viewer;
|
||||
terrainRef.current = terrain;
|
||||
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
|
||||
gridController = new GridLayerController(viewer);
|
||||
const rebuildGrid = () => gridController?.rebuild(presentationRef.current);
|
||||
rebuildGridRef.current = rebuildGrid;
|
||||
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
|
||||
rebuildGrid();
|
||||
if (presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid();
|
||||
syncHGeoZoneVisibility(viewer!, hGeoZoneLayersRef.current);
|
||||
onCameraChangeRef.current?.(getCameraView(viewer!));
|
||||
});
|
||||
removeGridCameraChangedListener = viewer.camera.changed.addEventListener(() => {
|
||||
if (!presentationRef.current.gridRebuildOnMoveEnd) rebuildGrid();
|
||||
});
|
||||
|
||||
const providerStatus: MapProviderStatus = {
|
||||
imagery: config?.gatewayReady ? "loading" : "not-configured",
|
||||
@@ -1818,10 +2013,12 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
onReadyChangeRef.current?.(false);
|
||||
resizeObserver?.disconnect();
|
||||
removeGridCameraListener?.();
|
||||
removeGridCameraChangedListener?.();
|
||||
removeRefreshRenderListener?.();
|
||||
removeRenderErrorListener?.();
|
||||
for (const removeListener of removeProviderFailureListeners) removeListener();
|
||||
handler?.destroy();
|
||||
gridController?.destroy();
|
||||
if (viewer && !viewer.isDestroyed()) viewer.destroy();
|
||||
viewerRef.current = null;
|
||||
imageryLayerRef.current = null;
|
||||
|
||||
@@ -53,6 +53,10 @@ type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
|
||||
type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number };
|
||||
const RENDERER_GATEWAY_HEALTH_EPOCH = 1;
|
||||
const SURVEY_GATEWAY_HEALTH_MAX_AGE_MS = 30_000;
|
||||
const GRID_MODE_OPTIONS: Array<SelectOption<"3d" | "graticule">> = [
|
||||
{ value: "3d", label: "3D", description: "Приподнятая пространственная сетка" },
|
||||
{ value: "graticule", label: "Гратикула", description: "Проекция по поверхности" },
|
||||
];
|
||||
|
||||
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
|
||||
order.nextEpoch += 1;
|
||||
@@ -221,20 +225,45 @@ const initialMapSettings: MapPageSettings = {
|
||||
imagerySaturation: 0,
|
||||
gridVisible: true,
|
||||
gridLodEnabled: true,
|
||||
grid3dEnabled: true,
|
||||
gridGraticuleEnabled: true,
|
||||
gridCenterMode: "camera",
|
||||
gridCenterLatitude: 55.7558,
|
||||
gridCenterLongitude: 37.6173,
|
||||
gridTileSizeKm: 10,
|
||||
gridAutoDisableHeightKm: 10_000,
|
||||
gridRebuildOnMoveEnd: true,
|
||||
gridMax3dViewAngleDegrees: 30,
|
||||
gridHeightMeters: 500,
|
||||
gridLod1MaxHeightKm: 10,
|
||||
gridLod1StepKm: 1,
|
||||
gridLod1Mode: "3d",
|
||||
gridLod2MaxHeightKm: 50,
|
||||
gridLod2StepKm: 5,
|
||||
gridLod2Mode: "3d",
|
||||
gridLod3MaxHeightKm: 180,
|
||||
gridLod3StepKm: 25,
|
||||
gridRadiusKm: 40,
|
||||
gridLod3Mode: "3d",
|
||||
gridLod4MaxHeightKm: 700,
|
||||
gridLod4StepKm: 100,
|
||||
gridLod4Mode: "graticule",
|
||||
gridLod5StepKm: 500,
|
||||
gridLod5Mode: "graticule",
|
||||
gridRadiusKm: 1_000,
|
||||
gridLineWidth: 4,
|
||||
gridLineDiameterMeters: 10,
|
||||
gridColor: "#f5f5f5",
|
||||
gridOpacity: 12,
|
||||
gridDotsEnabled: true,
|
||||
gridDotsSize: 7,
|
||||
gridDotsDiameterMeters: 80,
|
||||
gridDotsColor: "#ffffff",
|
||||
gridDotsOpacity: 58,
|
||||
gridCrossesEnabled: false,
|
||||
gridCrossesLengthMeters: 200,
|
||||
gridCrossesWidthMeters: 10,
|
||||
gridCrossesColor: "#35cfff",
|
||||
gridCrossesOpacity: 50,
|
||||
};
|
||||
|
||||
// A valid, deterministic scene view is available before Cesium emits its
|
||||
@@ -1238,23 +1267,57 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
description: "first adapter control",
|
||||
group: "Слои",
|
||||
content: <>
|
||||
<small className="catalog-map-inspector__note">Сетка размещается над поверхностью и меняет шаг по высоте камеры.</small>
|
||||
<Checker checked={mapSettings.gridVisible} label="3D-сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
||||
<small className="catalog-map-inspector__note">Пять 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 })} />
|
||||
<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 })} />
|
||||
<RangeControl label="Радиус сетки" value={mapSettings.gridRadiusKm} min={5} max={150} formatValue={(value) => `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} />
|
||||
<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="Толщина линий" value={mapSettings.gridLineWidth} min={1} max={8} formatValue={(value) => `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} />
|
||||
<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.gridDotsSize} min={2} max={28} formatValue={(value) => `${value} px`} onChange={(gridDotsSize) => updateMapSettings({ gridDotsSize })} />
|
||||
<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.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 })} />
|
||||
</>,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MapPresentation } from "./CesiumMapRenderer.js";
|
||||
|
||||
export type GridLodMode = "3d" | "graticule";
|
||||
export type GridLodBand = {
|
||||
index: number;
|
||||
id: string;
|
||||
maxHeightKm: number;
|
||||
stepKm: number;
|
||||
mode: GridLodMode;
|
||||
};
|
||||
|
||||
export const GRID_LOD_HYSTERESIS_RATIO: number;
|
||||
export function gridLodBands(settings: MapPresentation): GridLodBand[];
|
||||
export function selectGridLod(
|
||||
settings: MapPresentation,
|
||||
cameraHeightKm: number,
|
||||
previousIndex?: number | null,
|
||||
): GridLodBand;
|
||||
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,
|
||||
): { latitude: number; longitude: number };
|
||||
@@ -0,0 +1,90 @@
|
||||
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));
|
||||
|
||||
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) => ({
|
||||
index,
|
||||
id: `lod-${index + 1}`,
|
||||
maxHeightKm,
|
||||
stepKm: clamp(steps[index], 0.25, 5_000),
|
||||
mode: modes[index],
|
||||
}));
|
||||
}
|
||||
|
||||
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));
|
||||
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 lowerHold = lowerBoundary * (1 - GRID_LOD_HYSTERESIS_RATIO);
|
||||
const upperHold = Number.isFinite(upperBoundary)
|
||||
? upperBoundary * (1 + GRID_LOD_HYSTERESIS_RATIO)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return height >= lowerHold && height <= upperHold ? previous : bands[directIndex];
|
||||
}
|
||||
|
||||
export function resolveGridMode(settings, requestedMode, viewAngleFromNadirDegrees) {
|
||||
if (requestedMode === "3d") {
|
||||
const maxAngle = clamp(finite(settings.gridMax3dViewAngleDegrees, 30), 0, 89);
|
||||
if (settings.grid3dEnabled !== false && viewAngleFromNadirDegrees <= maxAngle) return "3d";
|
||||
return settings.gridGraticuleEnabled === false ? "hidden" : "graticule";
|
||||
}
|
||||
if (settings.gridGraticuleEnabled !== false) return "graticule";
|
||||
return settings.grid3dEnabled === false ? "hidden" : "3d";
|
||||
}
|
||||
|
||||
export function gridShouldBeVisible(settings, cameraHeightKm) {
|
||||
if (!settings.gridVisible) return false;
|
||||
const limit = finite(settings.gridAutoDisableHeightKm, 10_000);
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user