feat(map): add functional sector grid v2
This commit is contained in:
@@ -43,7 +43,16 @@ import {
|
||||
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";
|
||||
import {
|
||||
MAX_LOCAL_GRID_INDEX,
|
||||
graticuleSectorAt,
|
||||
graticuleSectorSummary,
|
||||
localSectorAt,
|
||||
localSectorSummary,
|
||||
localVolumeAt,
|
||||
type GraticuleSectorAddress,
|
||||
type LocalSectorAddress,
|
||||
} from "./mapSectorGrid.mjs";
|
||||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
@@ -62,6 +71,239 @@ const GRID_MODE_OPTIONS: Array<SelectOption<"3d" | "graticule">> = [
|
||||
{ value: "graticule", label: "Гратикула", description: "Проекция по поверхности" },
|
||||
];
|
||||
|
||||
type SectorGridLodProfile = GridLodProfile & {
|
||||
majorLinesEnabled: boolean;
|
||||
majorLabelsEnabled: boolean;
|
||||
majorLineWidthMultiplier: number;
|
||||
volumeEnabled: boolean;
|
||||
volumeMinimumHeightMeters: number;
|
||||
volumeMaximumHeightMeters: number;
|
||||
volumeBandHeightMeters: number;
|
||||
};
|
||||
|
||||
type GridSectorCopyState = "idle" | "copied" | "error";
|
||||
|
||||
const normalizedMajorTileSizeKm = (stepKm: number, requestedTileSizeKm: number) => {
|
||||
const safeStepKm = Math.min(50, Math.max(0.1, stepKm));
|
||||
const maximumRatio = Math.max(1, Math.floor((50 + Number.EPSILON) / safeStepKm));
|
||||
const requestedRatio = Math.max(1, Math.ceil((requestedTileSizeKm - Number.EPSILON) / safeStepKm));
|
||||
const ratio = Math.min(maximumRatio, requestedRatio);
|
||||
return Number((safeStepKm * ratio).toFixed(6));
|
||||
};
|
||||
|
||||
const normalizedGraticuleStepDegrees = (requestedStepDegrees: number) => {
|
||||
const safeStepDegrees = Math.min(10, Math.max(0.1, requestedStepDegrees));
|
||||
const requestedDivisions = Math.max(1, Math.round(180 / safeStepDegrees));
|
||||
// Five minor intervals form one major tile and each 90° quadrant must end
|
||||
// on a major boundary. A hemisphere therefore needs a multiple of ten
|
||||
// minor intervals.
|
||||
const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10);
|
||||
return 180 / hemisphereDivisions;
|
||||
};
|
||||
|
||||
const graticuleMajorStepDegrees = (stepDegrees: number) => {
|
||||
const candidate = stepDegrees * 5;
|
||||
const quadrantBands = 90 / candidate;
|
||||
return Math.abs(quadrantBands - Math.round(quadrantBands)) <= 1e-9 * Math.max(1, Math.abs(quadrantBands))
|
||||
? candidate
|
||||
: null;
|
||||
};
|
||||
|
||||
const normalizeSectorGridLodProfile = (profile: SectorGridLodProfile): SectorGridLodProfile => {
|
||||
const stepKm = profile.mode === "3d" ? Math.min(50, profile.stepKm) : profile.stepKm;
|
||||
const volumeMinimumHeightMeters = profile.volumeMinimumHeightMeters;
|
||||
const volumeMaximumHeightMeters = Math.max(volumeMinimumHeightMeters + 1, profile.volumeMaximumHeightMeters);
|
||||
return {
|
||||
...profile,
|
||||
stepKm,
|
||||
tileSizeKm: profile.mode === "3d"
|
||||
? normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, profile.tileSizeKm))
|
||||
: profile.tileSizeKm,
|
||||
graticuleStepDegrees: profile.mode === "graticule"
|
||||
? normalizedGraticuleStepDegrees(profile.graticuleStepDegrees)
|
||||
: profile.graticuleStepDegrees,
|
||||
majorLabelsEnabled: profile.majorLinesEnabled && profile.majorLabelsEnabled,
|
||||
volumeEnabled: profile.mode === "3d" && profile.volumeEnabled,
|
||||
volumeMinimumHeightMeters,
|
||||
volumeMaximumHeightMeters,
|
||||
volumeBandHeightMeters: Math.min(
|
||||
volumeMaximumHeightMeters - volumeMinimumHeightMeters,
|
||||
Math.max(1, profile.volumeBandHeightMeters),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
type GridSectorDirection = "north" | "east" | "south" | "west";
|
||||
|
||||
const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [
|
||||
{ id: "north", label: "Север" },
|
||||
{ id: "east", label: "Восток" },
|
||||
{ id: "south", label: "Юг" },
|
||||
{ id: "west", label: "Запад" },
|
||||
];
|
||||
|
||||
function localGridSectorSelection(
|
||||
address: LocalSectorAddress,
|
||||
profile: SectorGridLodProfile,
|
||||
origin: { latitude: number; longitude: number },
|
||||
preferredAltitudeMeters?: number,
|
||||
): GridSectorSelection {
|
||||
const definition = {
|
||||
lod: address.lod,
|
||||
originLatitude: origin.latitude,
|
||||
originLongitude: origin.longitude,
|
||||
stepMeters: profile.stepKm * 1_000,
|
||||
tileSizeMeters: profile.tileSizeKm * 1_000,
|
||||
};
|
||||
const summary = localSectorSummary(address, definition);
|
||||
const volumeSpan = profile.volumeMaximumHeightMeters - profile.volumeMinimumHeightMeters;
|
||||
const volume = profile.volumeEnabled && volumeSpan > 0
|
||||
? (() => {
|
||||
const altitudeMeters = Math.min(
|
||||
profile.volumeMaximumHeightMeters - Number.EPSILON,
|
||||
Math.max(
|
||||
profile.volumeMinimumHeightMeters,
|
||||
preferredAltitudeMeters ?? profile.volumeMinimumHeightMeters + Math.min(profile.volumeBandHeightMeters, volumeSpan) / 2,
|
||||
),
|
||||
);
|
||||
const volumeAddress = localVolumeAt({ ...summary.center, altitudeMeters }, {
|
||||
lod: address.lod,
|
||||
originLatitude: origin.latitude,
|
||||
originLongitude: origin.longitude,
|
||||
stepMeters: profile.stepKm * 1_000,
|
||||
altitudeFloorMeters: profile.volumeMinimumHeightMeters,
|
||||
altitudeCeilingMeters: profile.volumeMaximumHeightMeters,
|
||||
altitudeBandMeters: profile.volumeBandHeightMeters,
|
||||
});
|
||||
if (!volumeAddress) return null;
|
||||
return {
|
||||
id: volumeAddress.id,
|
||||
index: volumeAddress.bandIndex,
|
||||
floor: Math.max(profile.volumeMinimumHeightMeters, volumeAddress.altitudeFloorMeters),
|
||||
ceiling: Math.min(profile.volumeMaximumHeightMeters, volumeAddress.altitudeCeilingMeters),
|
||||
bandHeight: volumeAddress.altitudeBandMeters,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
return {
|
||||
...summary,
|
||||
mode: "3d",
|
||||
address,
|
||||
units: "meters-enu",
|
||||
volume,
|
||||
};
|
||||
}
|
||||
|
||||
function graticuleGridSectorSelection(
|
||||
address: GraticuleSectorAddress,
|
||||
profile: SectorGridLodProfile,
|
||||
): GridSectorSelection {
|
||||
const majorStepDegrees = profile.majorLinesEnabled
|
||||
? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined
|
||||
: undefined;
|
||||
const summary = graticuleSectorSummary(address, {
|
||||
lod: address.lod,
|
||||
stepDegrees: profile.graticuleStepDegrees,
|
||||
majorStepDegrees,
|
||||
});
|
||||
return {
|
||||
...summary,
|
||||
mode: "graticule",
|
||||
address,
|
||||
units: "degrees-wgs84",
|
||||
volume: null,
|
||||
};
|
||||
}
|
||||
|
||||
function gridSectorNeighborSelection(
|
||||
selection: GridSectorSelection,
|
||||
direction: GridSectorDirection,
|
||||
profiles: SectorGridLodProfile[],
|
||||
origin: { latitude: number; longitude: number },
|
||||
) {
|
||||
const profile = profiles[selection.lod - 1];
|
||||
if (!profile) return null;
|
||||
if (selection.mode === "3d") {
|
||||
const neighbor = selection.neighbors[direction];
|
||||
if (!neighbor) return null;
|
||||
const preferredAltitudeMeters = selection.volume
|
||||
? (selection.volume.floor + selection.volume.ceiling) / 2
|
||||
: undefined;
|
||||
return localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters);
|
||||
}
|
||||
const neighbor = selection.neighbors[direction];
|
||||
return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null;
|
||||
}
|
||||
|
||||
function gridSectorParentLodSelection(
|
||||
selection: GridSectorSelection,
|
||||
profiles: SectorGridLodProfile[],
|
||||
origin: { latitude: number; longitude: number },
|
||||
) {
|
||||
const parentProfile = profiles[selection.lod];
|
||||
if (!parentProfile || parentProfile.mode !== selection.mode) return null;
|
||||
if (selection.mode === "3d") {
|
||||
const address = localSectorAt(selection.center, {
|
||||
lod: selection.lod + 1,
|
||||
originLatitude: origin.latitude,
|
||||
originLongitude: origin.longitude,
|
||||
stepMeters: parentProfile.stepKm * 1_000,
|
||||
});
|
||||
const preferredAltitudeMeters = selection.volume
|
||||
? (selection.volume.floor + selection.volume.ceiling) / 2
|
||||
: undefined;
|
||||
return localGridSectorSelection(address, parentProfile, origin, preferredAltitudeMeters);
|
||||
}
|
||||
const address = graticuleSectorAt(selection.center, {
|
||||
lod: selection.lod + 1,
|
||||
stepDegrees: parentProfile.graticuleStepDegrees,
|
||||
});
|
||||
return graticuleGridSectorSelection(address, parentProfile);
|
||||
}
|
||||
|
||||
function gridSectorVolumeNeighborSelection(
|
||||
selection: GridSectorSelection,
|
||||
direction: "above" | "below",
|
||||
profile: SectorGridLodProfile | null,
|
||||
origin: { latitude: number; longitude: number },
|
||||
) {
|
||||
if (selection.mode !== "3d" || !selection.volume || !profile?.volumeEnabled) return null;
|
||||
const targetIndex = selection.volume.index + (direction === "above" ? 1 : -1);
|
||||
const targetFloorMeters = profile.volumeMinimumHeightMeters + targetIndex * profile.volumeBandHeightMeters;
|
||||
if (targetIndex < 0 || targetFloorMeters >= profile.volumeMaximumHeightMeters) return null;
|
||||
const targetCeilingMeters = Math.min(
|
||||
profile.volumeMaximumHeightMeters,
|
||||
targetFloorMeters + profile.volumeBandHeightMeters,
|
||||
);
|
||||
return localGridSectorSelection(
|
||||
selection.address,
|
||||
profile,
|
||||
origin,
|
||||
(targetFloorMeters + targetCeilingMeters) / 2,
|
||||
);
|
||||
}
|
||||
|
||||
const formatGridMetric = (value: number, maximumFractionDigits = 1) => value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits,
|
||||
});
|
||||
|
||||
const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000
|
||||
? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²`
|
||||
: `${formatGridMetric(areaSquareMeters, 0)} м²`;
|
||||
|
||||
function gridSectorBoundsLabel(selection: GridSectorSelection) {
|
||||
const { west, east, south, north } = selection.bounds;
|
||||
return selection.mode === "3d"
|
||||
? `E ${formatGridMetric(west)}…${formatGridMetric(east)} м · N ${formatGridMetric(south)}…${formatGridMetric(north)} м`
|
||||
: `λ ${formatGridMetric(west, 6)}…${formatGridMetric(east, 6)}° · φ ${formatGridMetric(south, 6)}…${formatGridMetric(north, 6)}°`;
|
||||
}
|
||||
|
||||
function gridSectorCenterLabel(selection: GridSectorSelection) {
|
||||
return selection.mode === "3d"
|
||||
? `E ${formatGridMetric(selection.center.eastMeters)} м · N ${formatGridMetric(selection.center.northMeters)} м`
|
||||
: `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`;
|
||||
}
|
||||
|
||||
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
|
||||
order.nextEpoch += 1;
|
||||
order.latestStartedEpoch = order.nextEpoch;
|
||||
@@ -284,7 +526,9 @@ function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): GridLodPro
|
||||
gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [],
|
||||
cacheRefresh: false,
|
||||
};
|
||||
return Array.from({ length: 5 }, (_unused, index) => gridLodProfile(legacySettings, index) as GridLodProfile);
|
||||
return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile(
|
||||
gridLodProfile(legacySettings, index) as SectorGridLodProfile,
|
||||
));
|
||||
}
|
||||
|
||||
// A valid, deterministic scene view is available before Cesium emits its
|
||||
@@ -411,6 +655,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [selectedGridSector, setSelectedGridSector] = useState<GridSectorSelection | null>(null);
|
||||
const [gridSectorCopyState, setGridSectorCopyState] = useState<GridSectorCopyState>("idle");
|
||||
const [subjectCardOpen, setSubjectCardOpen] = useState(false);
|
||||
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
|
||||
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
|
||||
@@ -675,18 +920,75 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
);
|
||||
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 activeGridLod = (mapSettings.gridLodProfiles[selectedGridLodIndex]
|
||||
?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex]) as SectorGridLodProfile;
|
||||
const sectorGridLodProfiles = mapSettings.gridLodProfiles as SectorGridLodProfile[];
|
||||
const gridSectorDefinitionKey = useMemo(() => JSON.stringify({
|
||||
origin: [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude],
|
||||
profiles: sectorGridLodProfiles.map((profile) => ({
|
||||
mode: profile.mode,
|
||||
stepKm: profile.stepKm,
|
||||
tileSizeKm: profile.tileSizeKm,
|
||||
graticuleStepDegrees: profile.graticuleStepDegrees,
|
||||
majorLinesEnabled: profile.majorLinesEnabled,
|
||||
volumeEnabled: profile.volumeEnabled,
|
||||
volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters,
|
||||
volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters,
|
||||
volumeBandHeightMeters: profile.volumeBandHeightMeters,
|
||||
})),
|
||||
}), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude, sectorGridLodProfiles]);
|
||||
const fixedSectorGridOrigin = useMemo(() => ({
|
||||
latitude: mapSettings.gridCenterLatitude,
|
||||
longitude: mapSettings.gridCenterLongitude,
|
||||
}), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude]);
|
||||
const selectedGridParentLod = useMemo(() => selectedGridSector
|
||||
? gridSectorParentLodSelection(selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin)
|
||||
: null, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]);
|
||||
const selectedGridNeighborTargets = useMemo(() => Object.fromEntries(
|
||||
GRID_SECTOR_DIRECTIONS.map(({ id }) => [id, selectedGridSector
|
||||
? gridSectorNeighborSelection(selectedGridSector, id, sectorGridLodProfiles, fixedSectorGridOrigin)
|
||||
: null]),
|
||||
) as Record<GridSectorDirection, GridSectorSelection | null>, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]);
|
||||
const selectedGridSectorProfile = selectedGridSector
|
||||
? sectorGridLodProfiles[selectedGridSector.lod - 1] ?? null
|
||||
: null;
|
||||
const selectedGridVolumeTargets = useMemo(() => ({
|
||||
above: selectedGridSector
|
||||
? gridSectorVolumeNeighborSelection(selectedGridSector, "above", selectedGridSectorProfile, fixedSectorGridOrigin)
|
||||
: null,
|
||||
below: selectedGridSector
|
||||
? gridSectorVolumeNeighborSelection(selectedGridSector, "below", selectedGridSectorProfile, fixedSectorGridOrigin)
|
||||
: null,
|
||||
}), [fixedSectorGridOrigin, selectedGridSector, selectedGridSectorProfile]);
|
||||
const activeGraticuleMajorStepDegrees = activeGridLod.mode === "graticule"
|
||||
? graticuleMajorStepDegrees(activeGridLod.graticuleStepDegrees)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedGridSector(null);
|
||||
}, [gridSectorDefinitionKey]);
|
||||
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({
|
||||
const updateGridLod = (patch: Partial<SectorGridLodProfile>) => updateMapSettings({
|
||||
gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => (
|
||||
index === selectedGridLodIndex ? { ...profile, ...patch } : profile
|
||||
)),
|
||||
});
|
||||
const updateGridVolumeRange = (patch: Partial<Pick<SectorGridLodProfile,
|
||||
"volumeMinimumHeightMeters" | "volumeMaximumHeightMeters" | "volumeBandHeightMeters">>) => {
|
||||
const minimum = patch.volumeMinimumHeightMeters ?? activeGridLod.volumeMinimumHeightMeters;
|
||||
const maximum = Math.max(minimum + 1, patch.volumeMaximumHeightMeters ?? activeGridLod.volumeMaximumHeightMeters);
|
||||
const span = maximum - minimum;
|
||||
updateGridLod({
|
||||
volumeMinimumHeightMeters: minimum,
|
||||
volumeMaximumHeightMeters: maximum,
|
||||
volumeBandHeightMeters: Math.min(span, Math.max(1, patch.volumeBandHeightMeters ?? activeGridLod.volumeBandHeightMeters)),
|
||||
});
|
||||
};
|
||||
const setCacheEnabled = (cacheEnabled: boolean) => {
|
||||
updateMapSettings({ cacheEnabled });
|
||||
setRendererRevision((value) => value + 1);
|
||||
@@ -711,6 +1013,29 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
setMapCamera(camera);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setGridSectorCopyState("idle");
|
||||
}, [selectedGridSector?.id]);
|
||||
|
||||
const copySelectedGridSectorId = useCallback(async () => {
|
||||
if (!selectedGridSector) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedGridSector.id);
|
||||
setGridSectorCopyState("copied");
|
||||
} catch {
|
||||
setGridSectorCopyState("error");
|
||||
}
|
||||
}, [selectedGridSector]);
|
||||
|
||||
const focusGridSector = useCallback((sector: GridSectorSelection | null) => {
|
||||
if (!sector || !mapRendererRef.current?.focusGridSector(sector)) return;
|
||||
setSelectedGridSector(sector);
|
||||
}, []);
|
||||
|
||||
const focusGridMajorTile = useCallback((tile: NonNullable<GridSectorSelection["parentMajorTile"]>) => {
|
||||
mapRendererRef.current?.focusGridMajorTile(tile);
|
||||
}, []);
|
||||
|
||||
const handleSpiralStateChange = useCallback((state: CameraSpiralState) => {
|
||||
setSpiralRunning(state.running);
|
||||
setSpiralMessage(state.running ? null : spiralStopMessage(state.reason));
|
||||
@@ -1323,11 +1648,60 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
</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 })} />
|
||||
<InspectorSelectField
|
||||
label="Режим"
|
||||
value={activeGridLod.mode}
|
||||
options={GRID_MODE_OPTIONS}
|
||||
onChange={(mode) => updateGridLod({
|
||||
mode,
|
||||
volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled,
|
||||
...(mode === "3d" ? {
|
||||
stepKm: Math.min(50, activeGridLod.stepKm),
|
||||
tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm),
|
||||
} : {
|
||||
graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees),
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
<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.stepKm}
|
||||
min={0.1}
|
||||
max={activeGridLod.mode === "3d" ? 50 : 5_000}
|
||||
step={0.1}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(stepKm) => updateGridLod({
|
||||
stepKm,
|
||||
tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)),
|
||||
radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX),
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Размер major-тайла ENU"
|
||||
value={activeGridLod.tileSizeKm}
|
||||
min={activeGridLod.stepKm}
|
||||
max={Math.max(activeGridLod.stepKm, 50)}
|
||||
step={activeGridLod.stepKm}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })}
|
||||
/>
|
||||
<small className="catalog-map-inspector__note">Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам.</small>
|
||||
<Checker
|
||||
checked={activeGridLod.majorLinesEnabled}
|
||||
label="Major-линии"
|
||||
onChange={(majorLinesEnabled) => updateGridLod({
|
||||
majorLinesEnabled,
|
||||
majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled,
|
||||
})}
|
||||
/>
|
||||
<Checker checked={activeGridLod.majorLabelsEnabled} disabled={!activeGridLod.majorLinesEnabled} label="Подписи major-тайлов" onChange={(majorLabelsEnabled) => updateGridLod({ majorLabelsEnabled })} />
|
||||
<RangeControl label="Толщина major-линий" value={activeGridLod.majorLineWidthMultiplier} min={1} max={8} step={0.1} formatValue={(value) => `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} />
|
||||
<small className="catalog-map-inspector__note">Прозрачность major-линий наследует прозрачность линий текущего LOD.</small>
|
||||
{activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null
|
||||
? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант.</small>
|
||||
: null}
|
||||
<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>
|
||||
@@ -1341,14 +1715,132 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
<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.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(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}
|
||||
{activeGridLod.mode === "3d" ? <>
|
||||
<Checker checked={activeGridLod.volumeEnabled} label="Объёмный выбор сектора" onChange={(volumeEnabled) => updateGridLod({ volumeEnabled })} />
|
||||
<RangeControl
|
||||
label="Нижняя отметка объёма"
|
||||
value={activeGridLod.volumeMinimumHeightMeters}
|
||||
min={-1_000}
|
||||
max={activeGridLod.volumeMaximumHeightMeters - 1}
|
||||
step={10}
|
||||
formatValue={(value) => `${value} м WGS84`}
|
||||
onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Верхняя отметка объёма"
|
||||
value={activeGridLod.volumeMaximumHeightMeters}
|
||||
min={activeGridLod.volumeMinimumHeightMeters + 1}
|
||||
max={10_000}
|
||||
step={10}
|
||||
formatValue={(value) => `${value} м WGS84`}
|
||||
onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Высота адресного диапазона"
|
||||
value={activeGridLod.volumeBandHeightMeters}
|
||||
min={1}
|
||||
max={Math.max(1, activeGridLod.volumeMaximumHeightMeters - activeGridLod.volumeMinimumHeightMeters)}
|
||||
step={10}
|
||||
formatValue={(value) => `${value} м`}
|
||||
onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })}
|
||||
/>
|
||||
<small className="catalog-map-inspector__note">Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки.</small>
|
||||
</> : null}
|
||||
<section className="catalog-map-grid-sector" aria-label="Выбранный сектор">
|
||||
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
|
||||
{selectedGridSector ? <>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
shape="pill"
|
||||
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
|
||||
onClick={() => void copySelectedGridSectorId()}
|
||||
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
|
||||
{gridSectorCopyState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Не удалось записать ID в буфер обмена.</small> : null}
|
||||
<div className="catalog-map-grid-sector__facts">
|
||||
<ControlRow label="Family / LOD"><strong>{selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod}</strong></ControlRow>
|
||||
<ControlRow label="Адрес"><span>{selectedGridSector.label}</span></ControlRow>
|
||||
<ControlRow label="Границы"><span>{gridSectorBoundsLabel(selectedGridSector)}</span></ControlRow>
|
||||
<ControlRow label="Центр"><span>{gridSectorCenterLabel(selectedGridSector)}</span></ControlRow>
|
||||
<ControlRow label="Площадь"><strong>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</strong></ControlRow>
|
||||
</div>
|
||||
{selectedGridSector.parentMajorTile ? <div className="catalog-map-grid-sector__relation">
|
||||
<small>Parent major tile · {selectedGridSector.parentMajorTile.label}</small>
|
||||
<code title={selectedGridSector.parentMajorTile.id}>{selectedGridSector.parentMajorTile.id}</code>
|
||||
<small>{selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)}</small>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
data-grid-navigation-intent="parent-major"
|
||||
disabled={!mapRendererReady}
|
||||
onClick={() => focusGridMajorTile(selectedGridSector.parentMajorTile!)}
|
||||
>Фокус major-тайла</Button>
|
||||
</div> : <small className="catalog-map-inspector__note">Parent major tile выключен или недоступен для текущей топологии.</small>}
|
||||
<div className="catalog-map-grid-sector__relation">
|
||||
<small>Следующий LOD</small>
|
||||
{selectedGridParentLod ? <>
|
||||
<code title={selectedGridParentLod.id}>{selectedGridParentLod.id}</code>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
data-grid-navigation-intent="next-lod"
|
||||
disabled={!mapRendererReady}
|
||||
onClick={() => focusGridSector(selectedGridParentLod)}
|
||||
>Перейти в LOD {selectedGridParentLod.lod}</Button>
|
||||
</> : <span>{selectedGridSector.lod >= sectorGridLodProfiles.length
|
||||
? "Верхний уровень иерархии"
|
||||
: `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}</span>}
|
||||
</div>
|
||||
<div className="catalog-map-grid-sector__neighbors" aria-label="Соседние сектора">
|
||||
{GRID_SECTOR_DIRECTIONS.map(({ id, label }) => {
|
||||
const target = selectedGridNeighborTargets[id];
|
||||
return <div className="catalog-map-grid-sector__neighbor" key={id}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
data-grid-navigation-intent={id}
|
||||
disabled={!target || !mapRendererReady}
|
||||
onClick={() => focusGridSector(target)}
|
||||
>{label}</Button>
|
||||
<code title={target?.id}>{target?.id ?? "Граница адресного пространства"}</code>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
{selectedGridSector.mode === "3d" && selectedGridSectorProfile ? <div className="catalog-map-grid-sector__volume" data-enabled={selectedGridSectorProfile.volumeEnabled || undefined}>
|
||||
<ControlRow label="Высотный выбор"><strong>{selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"}</strong></ControlRow>
|
||||
<ControlRow label="Floor"><span>{selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84</span></ControlRow>
|
||||
<ControlRow label="Ceiling"><span>{selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84</span></ControlRow>
|
||||
<ControlRow label="Height band"><span>{selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м</span></ControlRow>
|
||||
{selectedGridSector.volume ? <code title={selectedGridSector.volume.id}>{selectedGridSector.volume.id}</code> : null}
|
||||
{selectedGridSectorProfile.volumeEnabled ? <div className="catalog-map-grid-sector__volume-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
data-grid-navigation-intent="below"
|
||||
disabled={!selectedGridVolumeTargets.below || !mapRendererReady}
|
||||
onClick={() => focusGridSector(selectedGridVolumeTargets.below)}
|
||||
>Ниже</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
data-grid-navigation-intent="above"
|
||||
disabled={!selectedGridVolumeTargets.above || !mapRendererReady}
|
||||
onClick={() => focusGridSector(selectedGridVolumeTargets.above)}
|
||||
>Выше</Button>
|
||||
</div> : null}
|
||||
</div> : null}
|
||||
</> : <small className="catalog-map-inspector__note">Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.</small>}
|
||||
</section>
|
||||
</>,
|
||||
},
|
||||
{
|
||||
@@ -1479,6 +1971,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
ref={mapRendererRef}
|
||||
onSelect={handleSelect}
|
||||
onGridSectorSelect={setSelectedGridSector}
|
||||
selectedGridSector={selectedGridSector}
|
||||
onGatewayHealth={handleRendererGatewayHealth}
|
||||
onProviderStatus={setProviderStatus}
|
||||
onCameraChange={handleCameraChange}
|
||||
|
||||
Reference in New Issue
Block a user