feat(map): add functional sector grid v2

This commit is contained in:
Codex
2026-08-06 11:22:45 +03:00
parent 6f1fcb1eb0
commit d4827009d4
15 changed files with 2902 additions and 76 deletions
File diff suppressed because it is too large Load Diff
+505 -12
View File
@@ -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}
+7
View File
@@ -25,6 +25,13 @@ export type GridLodProfile = {
graticuleLineWidthPx: number;
graticuleColor: string;
graticuleOpacity: number;
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
};
export type GridLodBand = GridLodProfile & {
index: number;
+17
View File
@@ -16,6 +16,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300,
}),
Object.freeze({
maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -23,6 +25,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -30,6 +34,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -37,6 +43,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
// Engine keeps the last LOD selected above this threshold; the independent
@@ -46,6 +54,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
]);
@@ -81,6 +91,13 @@ export function gridLodProfile(settings, index) {
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),
majorLinesEnabled: source.majorLinesEnabled ?? fallback.majorLinesEnabled,
majorLabelsEnabled: source.majorLabelsEnabled ?? fallback.majorLabelsEnabled,
majorLineWidthMultiplier: clamp(finite(source.majorLineWidthMultiplier, fallback.majorLineWidthMultiplier), 1, 8),
volumeEnabled: source.volumeEnabled ?? fallback.volumeEnabled,
volumeMinimumHeightMeters: clamp(finite(source.volumeMinimumHeightMeters, fallback.volumeMinimumHeightMeters), -1_000, 10_000),
volumeMaximumHeightMeters: clamp(finite(source.volumeMaximumHeightMeters, fallback.volumeMaximumHeightMeters), -1_000, 10_000),
volumeBandHeightMeters: clamp(finite(source.volumeBandHeightMeters, fallback.volumeBandHeightMeters), 1, 10_000),
};
}
+208 -2
View File
@@ -1,3 +1,8 @@
export type LocalBounds = { west: number; east: number; south: number; north: number };
export type LocalCenter = { eastMeters: number; northMeters: number };
export type GeodeticBounds = { west: number; east: number; south: number; north: number };
export type GeodeticCenter = { longitude: number; latitude: number };
export type LocalGridDefinition = {
lod: number;
originLatitude: number;
@@ -5,6 +10,15 @@ export type LocalGridDefinition = {
stepMeters: number;
};
export type LocalHierarchyDefinition = LocalGridDefinition & { tileSizeMeters: number };
export type LocalVolumeDefinition = LocalGridDefinition & {
altitudeFloorMeters?: number;
altitudeCeilingMeters?: number;
/** @deprecated Use altitudeFloorMeters. */
altitudeOriginMeters?: number;
altitudeBandMeters: number;
};
export type LocalSectorAddress = {
family: "local-enu";
lod: number;
@@ -13,7 +27,29 @@ export type LocalSectorAddress = {
id: string;
};
export type LocalMajorTileAddress = {
family: "local-enu-major";
lod: number;
eastIndex: number;
northIndex: number;
minorPerSide: number;
id: string;
};
export type LocalVolumeAddress = {
family: "local-enu-volume";
lod: number;
eastIndex: number;
northIndex: number;
bandIndex: number;
altitudeFloorMeters: number;
altitudeCeilingMeters: number;
altitudeBandMeters: number;
id: string;
};
export type GraticuleDefinition = { lod: number; stepDegrees: number };
export type GraticuleHierarchyDefinition = GraticuleDefinition & { majorStepDegrees: number };
export type GraticuleSectorAddress = {
family: "wgs84-graticule";
lod: number;
@@ -22,15 +58,158 @@ export type GraticuleSectorAddress = {
id: string;
};
export type GraticuleMajorTileAddress = {
family: "wgs84-graticule-major";
lod: number;
longitudeIndex: number;
latitudeIndex: number;
minorPerSide: number;
id: string;
};
export type AddressDetail<Address, Bounds, Center> = {
address: Address;
bounds: Bounds;
center: Center;
areaSquareMeters: number;
};
export type LocalSectorDetail = AddressDetail<LocalSectorAddress, LocalBounds, LocalCenter>;
export type LocalMajorTileDetail = AddressDetail<LocalMajorTileAddress, LocalBounds, LocalCenter>;
export type GraticuleSectorDetail = AddressDetail<GraticuleSectorAddress, GeodeticBounds, GeodeticCenter>;
export type GraticuleMajorTileDetail = AddressDetail<GraticuleMajorTileAddress, GeodeticBounds, GeodeticCenter>;
export type LocalSectorSummary = {
id: string;
address: LocalSectorAddress;
family: "local-enu";
lod: number;
label: string;
indices: { eastIndex: number; northIndex: number };
bounds: LocalBounds;
center: LocalCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", LocalSectorDetail>;
majorTile: LocalMajorTileSummary | null;
parentMajorTile: LocalMajorTileSummary | null;
};
export type LocalMajorTileSummary = {
id: string;
address: LocalMajorTileAddress;
family: "local-enu-major";
lod: number;
label: string;
indices: { eastIndex: number; northIndex: number };
minorPerSide: number;
childCount: number;
bounds: LocalBounds;
center: LocalCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", LocalMajorTileDetail>;
};
export type LocalVolumeBounds = LocalBounds & {
altitudeFloorMeters: number;
altitudeCeilingMeters: number;
};
export type LocalVolumeSummary = {
id: string;
address: LocalVolumeAddress;
family: "local-enu-volume";
lod: number;
label: string;
indices: { eastIndex: number; northIndex: number; bandIndex: number };
bounds: LocalVolumeBounds;
center: LocalCenter & { altitudeMeters: number };
footprintAreaSquareMeters: number;
volumeCubicMeters: number;
};
export type GraticuleSectorSummary = {
id: string;
address: GraticuleSectorAddress;
family: "wgs84-graticule";
lod: number;
label: string;
indices: { longitudeIndex: number; latitudeIndex: number };
bounds: GeodeticBounds;
center: GeodeticCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", GraticuleSectorDetail | null>;
majorTile: GraticuleMajorTileSummary | null;
parentMajorTile: GraticuleMajorTileSummary | null;
};
export type GraticuleMajorTileSummary = {
id: string;
address: GraticuleMajorTileAddress;
family: "wgs84-graticule-major";
lod: number;
label: string;
indices: { longitudeIndex: number; latitudeIndex: number };
minorPerSide: number;
childCount: number;
bounds: GeodeticBounds;
center: GeodeticCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", GraticuleMajorTileDetail | null>;
};
export const MAX_LOCAL_GRID_INDEX: number;
export const MAX_GRID_CHILD_PAGE_SIZE: number;
export type GridChildPage = { offset?: number; limit?: number };
export function normalizeLongitudeDegrees(value: number): number;
export function fixedGridOrigin(settings: { gridCenterLatitude?: number; gridCenterLongitude?: number }): { latitude: number; longitude: number };
// Existing horizontal sector IDs are intentionally unchanged in v2.
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 localSectorBounds(address: LocalSectorAddress, stepMeters: number): LocalBounds;
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 localSectorCenter(address: LocalSectorAddress, stepMeters: number): LocalCenter;
export function localSectorAreaSquareMeters(address: LocalSectorAddress, stepMeters: number): number;
export function localSectorSummary(
address: LocalSectorAddress,
definition: LocalGridDefinition & { tileSizeMeters?: number },
): LocalSectorSummary;
export function localMajorTileId(definition: LocalHierarchyDefinition, eastIndex: number, northIndex: number): string;
export function localMajorTileAt(point: { eastMeters: number; northMeters: number }, definition: LocalHierarchyDefinition): LocalMajorTileAddress;
export function localMajorTileBounds(address: LocalMajorTileAddress, tileSizeMeters: number): LocalBounds;
export function localMajorTileForSector(address: LocalSectorAddress, definition: LocalHierarchyDefinition): LocalMajorTileAddress;
export function localMajorTileChildren(
address: LocalMajorTileAddress,
definition: LocalHierarchyDefinition,
options?: GridChildPage,
): LocalSectorAddress[];
export function localMajorTileNeighbors(address: LocalMajorTileAddress, definition: LocalHierarchyDefinition): Record<"north" | "east" | "south" | "west", LocalMajorTileAddress>;
export function isLocalMajorLineIndex(lineIndex: number, definition: LocalHierarchyDefinition): boolean;
export function localMajorTileCenter(address: LocalMajorTileAddress, tileSizeMeters: number): LocalCenter;
export function localMajorTileAreaSquareMeters(address: LocalMajorTileAddress, tileSizeMeters: number): number;
export function localMajorTileSummary(address: LocalMajorTileAddress, definition: LocalHierarchyDefinition): LocalMajorTileSummary;
export function localVolumeId(definition: LocalVolumeDefinition, eastIndex: number, northIndex: number, bandIndex: number): string;
export function localVolumeAt(
point: { eastMeters: number; northMeters: number; altitudeMeters: number },
definition: LocalVolumeDefinition,
): LocalVolumeAddress | null;
export function localVolumeBounds(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalVolumeBounds;
export function localVolumeNeighbors(address: LocalVolumeAddress, definition: LocalVolumeDefinition): {
north: LocalVolumeAddress;
east: LocalVolumeAddress;
south: LocalVolumeAddress;
west: LocalVolumeAddress;
above: LocalVolumeAddress | null;
below: LocalVolumeAddress | null;
};
export function localVolumeCenter(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalCenter & { altitudeMeters: number };
export function localVolumeSummary(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalVolumeSummary;
export function localGridPlan(input: { stepMeters: number; radiusMeters: number; maximumMarkers?: number }): {
stepMeters: number;
radiusMeters: number;
@@ -40,9 +219,36 @@ export function localGridPlan(input: { stepMeters: number; radiusMeters: number;
markerStride: number;
lines: Array<{ index: number; offsetMeters: number; extentMeters: number }>;
};
// Existing horizontal graticule IDs are intentionally unchanged in v2.
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 graticuleSectorBounds(address: GraticuleSectorAddress, stepDegrees: number): GeodeticBounds;
export function graticuleSectorNeighbors(address: GraticuleSectorAddress, definition: GraticuleDefinition): Record<"north" | "east" | "south" | "west", GraticuleSectorAddress | null>;
export function graticuleSectorCenter(address: GraticuleSectorAddress, stepDegrees: number): GeodeticCenter;
export function graticuleSectorAreaSquareMeters(address: GraticuleSectorAddress, stepDegrees: number): number;
export function graticuleSectorSummary(
address: GraticuleSectorAddress,
definition: GraticuleDefinition & { majorStepDegrees?: number },
): GraticuleSectorSummary;
export function graticuleMajorTileId(definition: GraticuleHierarchyDefinition, longitudeIndex: number, latitudeIndex: number): string;
export function graticuleMajorTileAt(point: { longitude: number; latitude: number }, definition: GraticuleHierarchyDefinition): GraticuleMajorTileAddress;
export function graticuleMajorTileBounds(address: GraticuleMajorTileAddress, majorStepDegrees: number): GeodeticBounds;
export function graticuleMajorTileForSector(address: GraticuleSectorAddress, definition: GraticuleHierarchyDefinition): GraticuleMajorTileAddress;
export function graticuleMajorTileChildren(
address: GraticuleMajorTileAddress,
definition: GraticuleHierarchyDefinition,
options?: GridChildPage,
): GraticuleSectorAddress[];
export function graticuleMajorTileNeighbors(address: GraticuleMajorTileAddress, definition: GraticuleHierarchyDefinition): Record<"north" | "east" | "south" | "west", GraticuleMajorTileAddress | null>;
export function isGraticuleMajorLineIndex(lineIndex: number, definition: GraticuleHierarchyDefinition): boolean;
export function isGraticuleMajorLineValue(valueDegrees: number, definition: GraticuleHierarchyDefinition): boolean;
export function graticuleMajorTileCenter(address: GraticuleMajorTileAddress, majorStepDegrees: number): GeodeticCenter;
export function graticuleMajorTileAreaSquareMeters(address: GraticuleMajorTileAddress, majorStepDegrees: number): number;
export function graticuleMajorTileSummary(address: GraticuleMajorTileAddress, definition: GraticuleHierarchyDefinition): GraticuleMajorTileSummary;
export function geodeticRectangleAreaSquareMeters(bounds: GeodeticBounds): 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 }>;
+730
View File
@@ -1,6 +1,10 @@
const EPSILON = 1e-9;
const WGS84_EQUATORIAL_RADIUS_METERS = 6_378_137;
const WGS84_FLATTENING = 1 / 298.257223563;
const WGS84_ECCENTRICITY_SQUARED = WGS84_FLATTENING * (2 - WGS84_FLATTENING);
const WGS84_ECCENTRICITY = Math.sqrt(WGS84_ECCENTRICITY_SQUARED);
export const MAX_LOCAL_GRID_INDEX = 512;
export const MAX_GRID_CHILD_PAGE_SIZE = 10_000;
const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
@@ -8,6 +12,42 @@ 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.");
const positiveNumber = (value, errorCode) => {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) throw new Error(errorCode);
return number;
};
const safeInteger = (value, errorCode = "grid_index_must_be_safe_integer") => {
const number = Number(value);
if (!Number.isSafeInteger(number)) throw new Error(errorCode);
return canonicalZero(number);
};
const integerMultiple = (outer, inner, errorCode) => {
const ratio = outer / inner;
const rounded = Math.round(ratio);
if (!Number.isSafeInteger(rounded)
|| rounded < 1
|| Math.abs(ratio - rounded) > EPSILON * Math.max(1, Math.abs(ratio))) {
throw new Error(errorCode);
}
return rounded;
};
function childPageRange(totalCount, options) {
const total = safeInteger(totalCount, "grid_child_count_must_be_safe_integer");
const offset = safeInteger(options?.offset ?? 0, "grid_child_offset_must_be_safe_integer");
if (offset < 0) throw new Error("grid_child_offset_must_be_non_negative");
const remaining = Math.max(0, total - offset);
const limit = options?.limit == null
? remaining
: safeInteger(options.limit, "grid_child_limit_must_be_safe_integer");
if (limit < 1 && remaining > 0) throw new Error("grid_child_limit_must_be_positive");
if (limit > MAX_GRID_CHILD_PAGE_SIZE) throw new Error("grid_child_page_limit_exceeded");
return { start: Math.min(offset, total), end: Math.min(total, offset + Math.max(0, limit)) };
}
export function normalizeLongitudeDegrees(value) {
const longitude = finite(value, 0);
return canonicalZero(((longitude + 180) % 360 + 360) % 360 - 180);
@@ -81,6 +121,360 @@ export function localParentSector(address, childStepMeters, parentDefinition) {
}, parentDefinition);
}
function localHierarchyMetrics(definition) {
const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive");
const tileSizeMeters = positiveNumber(definition?.tileSizeMeters, "grid_tile_size_must_be_positive");
const minorPerMajor = integerMultiple(
tileSizeMeters,
stepMeters,
"grid_tile_size_must_be_integer_multiple_of_step",
);
return { stepMeters, tileSizeMeters, minorPerMajor };
}
function localMajorDefinitionToken(definition, metrics = localHierarchyMetrics(definition)) {
return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/t${decimalToken(metrics.tileSizeMeters, 3)}`;
}
export function localMajorTileId(definition, eastIndex, northIndex) {
const metrics = localHierarchyMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
return `grid/local/${localMajorDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}`;
}
function localMajorAddress(definition, eastIndex, northIndex) {
const metrics = localHierarchyMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
return {
family: "local-enu-major",
lod: definition.lod,
eastIndex: east,
northIndex: north,
minorPerSide: metrics.minorPerMajor,
id: localMajorTileId(definition, east, north),
};
}
export function localMajorTileAt(point, definition) {
const { tileSizeMeters } = localHierarchyMetrics(definition);
return localMajorAddress(
definition,
Math.floor(finite(point?.eastMeters, 0) / tileSizeMeters),
Math.floor(finite(point?.northMeters, 0) / tileSizeMeters),
);
}
export function localMajorTileBounds(address, tileSizeMeters) {
const size = positiveNumber(tileSizeMeters, "grid_tile_size_must_be_positive");
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
return {
west: eastIndex * size,
east: (eastIndex + 1) * size,
south: northIndex * size,
north: (northIndex + 1) * size,
};
}
export function localMajorTileForSector(address, definition) {
const { minorPerMajor } = localHierarchyMetrics(definition);
return localMajorAddress(
definition,
Math.floor(safeInteger(address?.eastIndex) / minorPerMajor),
Math.floor(safeInteger(address?.northIndex) / minorPerMajor),
);
}
export function localMajorTileChildren(address, definition, options) {
const { minorPerMajor } = localHierarchyMetrics(definition);
const majorEastIndex = safeInteger(address?.eastIndex);
const majorNorthIndex = safeInteger(address?.northIndex);
const firstEastIndex = safeInteger(majorEastIndex * minorPerMajor);
const firstNorthIndex = safeInteger(majorNorthIndex * minorPerMajor);
safeInteger(firstEastIndex + minorPerMajor - 1);
safeInteger(firstNorthIndex + minorPerMajor - 1);
const page = childPageRange(minorPerMajor ** 2, options);
const children = [];
for (let childIndex = page.start; childIndex < page.end; childIndex += 1) {
const eastIndex = firstEastIndex + childIndex % minorPerMajor;
const northIndex = firstNorthIndex + Math.floor(childIndex / minorPerMajor);
children.push({
family: "local-enu",
lod: definition.lod,
eastIndex,
northIndex,
id: localSectorId(definition, eastIndex, northIndex),
});
}
return children;
}
export function localMajorTileNeighbors(address, definition) {
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
return {
north: localMajorAddress(definition, eastIndex, northIndex + 1),
east: localMajorAddress(definition, eastIndex + 1, northIndex),
south: localMajorAddress(definition, eastIndex, northIndex - 1),
west: localMajorAddress(definition, eastIndex - 1, northIndex),
};
}
export function isLocalMajorLineIndex(lineIndex, definition) {
const { minorPerMajor } = localHierarchyMetrics(definition);
return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0;
}
export function localSectorCenter(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return {
eastMeters: (bounds.west + bounds.east) / 2,
northMeters: (bounds.south + bounds.north) / 2,
};
}
export function localSectorAreaSquareMeters(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return (bounds.east - bounds.west) * (bounds.north - bounds.south);
}
function localSectorDetail(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return {
address,
bounds,
center: localSectorCenter(address, stepMeters),
areaSquareMeters: localSectorAreaSquareMeters(address, stepMeters),
};
}
export function localSectorSummary(address, definition) {
const bounds = localSectorBounds(address, definition.stepMeters);
const neighbors = Object.fromEntries(
Object.entries(localSectorNeighbors(address, definition))
.map(([direction, neighbor]) => [direction, localSectorDetail(neighbor, definition.stepMeters)]),
);
const majorTile = definition?.tileSizeMeters == null
? null
: localMajorTileSummary(localMajorTileForSector(address, definition), definition);
return {
id: address.id,
address,
family: "local-enu",
lod: address.lod,
label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`,
indices: { eastIndex: address.eastIndex, northIndex: address.northIndex },
bounds,
center: localSectorCenter(address, definition.stepMeters),
areaSquareMeters: localSectorAreaSquareMeters(address, definition.stepMeters),
neighbors,
majorTile,
parentMajorTile: majorTile,
};
}
export function localMajorTileCenter(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return {
eastMeters: (bounds.west + bounds.east) / 2,
northMeters: (bounds.south + bounds.north) / 2,
};
}
export function localMajorTileAreaSquareMeters(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return (bounds.east - bounds.west) * (bounds.north - bounds.south);
}
function localMajorTileDetail(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return {
address,
bounds,
center: localMajorTileCenter(address, tileSizeMeters),
areaSquareMeters: localMajorTileAreaSquareMeters(address, tileSizeMeters),
};
}
export function localMajorTileSummary(address, definition) {
const metrics = localHierarchyMetrics(definition);
const bounds = localMajorTileBounds(address, metrics.tileSizeMeters);
const neighbors = Object.fromEntries(
Object.entries(localMajorTileNeighbors(address, definition))
.map(([direction, neighbor]) => [direction, localMajorTileDetail(neighbor, metrics.tileSizeMeters)]),
);
return {
id: address.id,
address,
family: "local-enu-major",
lod: address.lod,
label: `L${address.lod} TILE E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`,
indices: { eastIndex: address.eastIndex, northIndex: address.northIndex },
minorPerSide: metrics.minorPerMajor,
childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"),
bounds,
center: localMajorTileCenter(address, metrics.tileSizeMeters),
areaSquareMeters: localMajorTileAreaSquareMeters(address, metrics.tileSizeMeters),
neighbors,
};
}
function localVolumeMetrics(definition) {
const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive");
const altitudeBandMeters = positiveNumber(
definition?.altitudeBandMeters,
"grid_altitude_band_must_be_positive",
);
const altitudeFloorMeters = Number(
definition?.altitudeFloorMeters ?? definition?.altitudeOriginMeters ?? 0,
);
if (!Number.isFinite(altitudeFloorMeters)) {
throw new Error("grid_altitude_floor_must_be_finite");
}
const altitudeCeilingMeters = definition?.altitudeCeilingMeters == null
? Number.POSITIVE_INFINITY
: Number(definition.altitudeCeilingMeters);
if ((!Number.isFinite(altitudeCeilingMeters) && altitudeCeilingMeters !== Number.POSITIVE_INFINITY)
|| altitudeCeilingMeters <= altitudeFloorMeters) {
throw new Error("grid_altitude_ceiling_must_exceed_floor");
}
return { stepMeters, altitudeBandMeters, altitudeFloorMeters, altitudeCeilingMeters };
}
function localVolumeDefinitionToken(definition, metrics = localVolumeMetrics(definition)) {
const ceilingToken = Number.isFinite(metrics.altitudeCeilingMeters)
? decimalToken(metrics.altitudeCeilingMeters, 3)
: "inf";
return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/f${decimalToken(metrics.altitudeFloorMeters, 3)}/c${ceilingToken}/h${decimalToken(metrics.altitudeBandMeters, 3)}`;
}
export function localVolumeId(definition, eastIndex, northIndex, bandIndex) {
const metrics = localVolumeMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (band < 0
|| metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
return `grid/local-volume/${localVolumeDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}/z${signedIndex(band)}`;
}
function localVolumeAddress(definition, eastIndex, northIndex, bandIndex) {
const metrics = localVolumeMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (band < 0) throw new Error("grid_altitude_band_index_out_of_range");
const altitudeFloorMeters = metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters;
if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
const altitudeCeilingMeters = Math.min(
metrics.altitudeCeilingMeters,
altitudeFloorMeters + metrics.altitudeBandMeters,
);
return {
family: "local-enu-volume",
lod: definition.lod,
eastIndex: east,
northIndex: north,
bandIndex: band,
altitudeFloorMeters,
altitudeCeilingMeters,
altitudeBandMeters: altitudeCeilingMeters - altitudeFloorMeters,
id: localVolumeId(definition, east, north, band),
};
}
export function localVolumeAt(point, definition) {
const metrics = localVolumeMetrics(definition);
const altitudeMeters = Number(point?.altitudeMeters);
if (!Number.isFinite(altitudeMeters)
|| altitudeMeters < metrics.altitudeFloorMeters
|| altitudeMeters >= metrics.altitudeCeilingMeters) {
return null;
}
const eastIndex = Math.floor(finite(point?.eastMeters, 0) / metrics.stepMeters);
const northIndex = Math.floor(finite(point?.northMeters, 0) / metrics.stepMeters);
const bandIndex = Math.floor(
(altitudeMeters - metrics.altitudeFloorMeters) / metrics.altitudeBandMeters,
);
return localVolumeAddress(definition, eastIndex, northIndex, bandIndex);
}
export function localVolumeBounds(address, definition) {
const metrics = localVolumeMetrics(definition);
const horizontal = localSectorBounds(address, metrics.stepMeters);
const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (bandIndex < 0) throw new Error("grid_altitude_band_index_out_of_range");
const altitudeFloorMeters = metrics.altitudeFloorMeters + bandIndex * metrics.altitudeBandMeters;
if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
return {
...horizontal,
altitudeFloorMeters,
altitudeCeilingMeters: Math.min(
metrics.altitudeCeilingMeters,
altitudeFloorMeters + metrics.altitudeBandMeters,
),
};
}
export function localVolumeNeighbors(address, definition) {
const metrics = localVolumeMetrics(definition);
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer");
const aboveFloor = metrics.altitudeFloorMeters + (bandIndex + 1) * metrics.altitudeBandMeters;
return {
north: localVolumeAddress(definition, eastIndex, northIndex + 1, bandIndex),
east: localVolumeAddress(definition, eastIndex + 1, northIndex, bandIndex),
south: localVolumeAddress(definition, eastIndex, northIndex - 1, bandIndex),
west: localVolumeAddress(definition, eastIndex - 1, northIndex, bandIndex),
above: aboveFloor >= metrics.altitudeCeilingMeters
? null
: localVolumeAddress(definition, eastIndex, northIndex, bandIndex + 1),
below: bandIndex === 0
? null
: localVolumeAddress(definition, eastIndex, northIndex, bandIndex - 1),
};
}
export function localVolumeCenter(address, definition) {
const horizontal = localSectorCenter(address, definition.stepMeters);
const bounds = localVolumeBounds(address, definition);
return {
...horizontal,
altitudeMeters: (bounds.altitudeFloorMeters + bounds.altitudeCeilingMeters) / 2,
};
}
export function localVolumeSummary(address, definition) {
const bounds = localVolumeBounds(address, definition);
const footprintAreaSquareMeters = (bounds.east - bounds.west) * (bounds.north - bounds.south);
return {
id: address.id,
address,
family: "local-enu-volume",
lod: address.lod,
label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)} Z${signedIndex(address.bandIndex)}`,
indices: {
eastIndex: address.eastIndex,
northIndex: address.northIndex,
bandIndex: address.bandIndex,
},
bounds,
center: localVolumeCenter(address, definition),
footprintAreaSquareMeters,
volumeCubicMeters: footprintAreaSquareMeters
* (bounds.altitudeCeilingMeters - bounds.altitudeFloorMeters),
};
}
export function localGridPlan({ stepMeters, radiusMeters, maximumMarkers = 5_000 }) {
const step = Math.max(1, finite(stepMeters, 1));
const requestedRadius = Math.max(step, finite(radiusMeters, step));
@@ -138,6 +532,342 @@ export function graticuleSectorBounds(address, stepDegrees) {
};
}
function graticuleTopology(stepDegrees, errorCode = "grid_graticule_step_must_partition_hemisphere") {
const step = positiveNumber(stepDegrees, "grid_graticule_step_must_be_positive");
const longitudeHemisphereCount = integerMultiple(180, step, errorCode);
const latitudeHemisphereCount = integerMultiple(90, step, errorCode);
return {
stepDegrees: step,
longitudeHemisphereCount,
latitudeHemisphereCount,
longitudeCount: longitudeHemisphereCount * 2,
latitudeCount: latitudeHemisphereCount * 2,
};
}
function wrapGraticuleIndex(index, hemisphereCount) {
const span = hemisphereCount * 2;
return canonicalZero(((index + hemisphereCount) % span + span) % span - hemisphereCount);
}
function graticuleSectorAddress(definition, longitudeIndex, latitudeIndex) {
const longitude = safeInteger(longitudeIndex);
const latitude = safeInteger(latitudeIndex);
return {
family: "wgs84-graticule",
lod: definition.lod,
longitudeIndex: longitude,
latitudeIndex: latitude,
id: graticuleSectorId(definition, longitude, latitude),
};
}
export function graticuleSectorNeighbors(address, definition) {
const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive");
const minimumLongitudeIndex = Math.floor(-180 / stepDegrees);
const maximumLongitudeIndex = Math.floor((180 - EPSILON) / stepDegrees);
const minimumLatitudeIndex = Math.floor(-90 / stepDegrees);
const maximumLatitudeIndex = Math.floor((90 - EPSILON) / stepDegrees);
const longitudeIndex = safeInteger(address?.longitudeIndex);
const latitudeIndex = safeInteger(address?.latitudeIndex);
if (longitudeIndex < minimumLongitudeIndex || longitudeIndex > maximumLongitudeIndex) {
throw new Error("grid_graticule_longitude_index_out_of_range");
}
if (latitudeIndex < minimumLatitudeIndex || latitudeIndex > maximumLatitudeIndex) {
throw new Error("grid_graticule_latitude_index_out_of_range");
}
return {
north: latitudeIndex === maximumLatitudeIndex
? null
: graticuleSectorAddress(definition, longitudeIndex, latitudeIndex + 1),
east: graticuleSectorAddress(
definition,
longitudeIndex === maximumLongitudeIndex ? minimumLongitudeIndex : longitudeIndex + 1,
latitudeIndex,
),
south: latitudeIndex === minimumLatitudeIndex
? null
: graticuleSectorAddress(definition, longitudeIndex, latitudeIndex - 1),
west: graticuleSectorAddress(
definition,
longitudeIndex === minimumLongitudeIndex ? maximumLongitudeIndex : longitudeIndex - 1,
latitudeIndex,
),
};
}
function graticuleHierarchyMetrics(definition) {
const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive");
const majorStepDegrees = positiveNumber(
definition?.majorStepDegrees,
"grid_graticule_major_step_must_be_positive",
);
const minorPerMajor = integerMultiple(
majorStepDegrees,
stepDegrees,
"grid_graticule_major_step_must_be_integer_multiple_of_step",
);
const topology = graticuleTopology(
majorStepDegrees,
"grid_graticule_major_step_must_partition_hemisphere",
);
return { ...topology, stepDegrees, majorStepDegrees, minorPerMajor };
}
function graticuleMajorDefinitionToken(definition, metrics = graticuleHierarchyMetrics(definition)) {
return `${graticuleDefinitionToken({ ...definition, stepDegrees: metrics.stepDegrees })}/m${decimalToken(metrics.majorStepDegrees, 6)}`;
}
export function graticuleMajorTileId(definition, longitudeIndex, latitudeIndex) {
const metrics = graticuleHierarchyMetrics(definition);
const longitude = wrapGraticuleIndex(
safeInteger(longitudeIndex),
metrics.longitudeHemisphereCount,
);
const latitude = safeInteger(latitudeIndex);
if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return `grid/${graticuleMajorDefinitionToken(definition, metrics)}/x${signedIndex(longitude)}/y${signedIndex(latitude)}`;
}
function graticuleMajorAddress(definition, longitudeIndex, latitudeIndex) {
const metrics = graticuleHierarchyMetrics(definition);
const longitude = wrapGraticuleIndex(safeInteger(longitudeIndex), metrics.longitudeHemisphereCount);
const latitude = safeInteger(latitudeIndex);
if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return {
family: "wgs84-graticule-major",
lod: definition.lod,
longitudeIndex: longitude,
latitudeIndex: latitude,
minorPerSide: metrics.minorPerMajor,
id: graticuleMajorTileId(definition, longitude, latitude),
};
}
export function graticuleMajorTileAt(point, definition) {
const { majorStepDegrees } = graticuleHierarchyMetrics(definition);
const longitude = normalizeLongitudeDegrees(point?.longitude);
const latitude = clamp(finite(point?.latitude, 0), -90, 90 - EPSILON);
return graticuleMajorAddress(
definition,
Math.floor(longitude / majorStepDegrees),
Math.floor(latitude / majorStepDegrees),
);
}
export function graticuleMajorTileBounds(address, majorStepDegrees) {
const topology = graticuleTopology(
majorStepDegrees,
"grid_graticule_major_step_must_partition_hemisphere",
);
const longitudeIndex = wrapGraticuleIndex(
safeInteger(address?.longitudeIndex),
topology.longitudeHemisphereCount,
);
const latitudeIndex = safeInteger(address?.latitudeIndex);
if (latitudeIndex < -topology.latitudeHemisphereCount
|| latitudeIndex >= topology.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return {
west: longitudeIndex * topology.stepDegrees,
east: (longitudeIndex + 1) * topology.stepDegrees,
south: latitudeIndex * topology.stepDegrees,
north: (latitudeIndex + 1) * topology.stepDegrees,
};
}
export function graticuleMajorTileForSector(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
return graticuleMajorAddress(
definition,
Math.floor(safeInteger(address?.longitudeIndex) / metrics.minorPerMajor),
Math.floor(safeInteger(address?.latitudeIndex) / metrics.minorPerMajor),
);
}
export function graticuleMajorTileChildren(address, definition, options) {
const metrics = graticuleHierarchyMetrics(definition);
const major = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex);
const firstLongitudeIndex = major.longitudeIndex * metrics.minorPerMajor;
const firstLatitudeIndex = major.latitudeIndex * metrics.minorPerMajor;
const page = childPageRange(metrics.minorPerMajor ** 2, options);
const children = [];
for (let childIndex = page.start; childIndex < page.end; childIndex += 1) {
children.push(graticuleSectorAddress(
definition,
firstLongitudeIndex + childIndex % metrics.minorPerMajor,
firstLatitudeIndex + Math.floor(childIndex / metrics.minorPerMajor),
));
}
return children;
}
export function graticuleMajorTileNeighbors(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
const tile = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex);
return {
north: tile.latitudeIndex === metrics.latitudeHemisphereCount - 1
? null
: graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex + 1),
east: graticuleMajorAddress(definition, tile.longitudeIndex + 1, tile.latitudeIndex),
south: tile.latitudeIndex === -metrics.latitudeHemisphereCount
? null
: graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex - 1),
west: graticuleMajorAddress(definition, tile.longitudeIndex - 1, tile.latitudeIndex),
};
}
export function isGraticuleMajorLineIndex(lineIndex, definition) {
const { minorPerMajor } = graticuleHierarchyMetrics(definition);
return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0;
}
export function isGraticuleMajorLineValue(valueDegrees, definition) {
const { majorStepDegrees } = graticuleHierarchyMetrics(definition);
const ratio = finite(valueDegrees, Number.NaN) / majorStepDegrees;
return Number.isFinite(ratio)
&& Math.abs(ratio - Math.round(ratio)) <= EPSILON * Math.max(1, Math.abs(ratio));
}
function authalicQ(latitudeRadians) {
const sine = Math.sin(latitudeRadians);
const eccentricitySine = WGS84_ECCENTRICITY * sine;
return (1 - WGS84_ECCENTRICITY_SQUARED) * (
sine / (1 - WGS84_ECCENTRICITY_SQUARED * sine * sine)
- Math.log((1 - eccentricitySine) / (1 + eccentricitySine)) / (2 * WGS84_ECCENTRICITY)
);
}
export function geodeticRectangleAreaSquareMeters(bounds) {
const south = clamp(finite(bounds?.south, -90), -90, 90);
const north = clamp(finite(bounds?.north, 90), -90, 90);
if (north <= south) return 0;
const rawWest = finite(bounds?.west, -180);
const rawEast = finite(bounds?.east, 180);
let longitudeSpanDegrees = rawEast - rawWest;
if (Math.abs(longitudeSpanDegrees) >= 360 - EPSILON) longitudeSpanDegrees = 360;
else if (longitudeSpanDegrees < 0) longitudeSpanDegrees += 360;
longitudeSpanDegrees = clamp(longitudeSpanDegrees, 0, 360);
const longitudeSpanRadians = longitudeSpanDegrees * Math.PI / 180;
const southRadians = south * Math.PI / 180;
const northRadians = north * Math.PI / 180;
return WGS84_EQUATORIAL_RADIUS_METERS ** 2
* longitudeSpanRadians
* Math.abs(authalicQ(northRadians) - authalicQ(southRadians))
/ 2;
}
function geodeticBoundsCenter(bounds) {
const width = bounds.east - bounds.west;
return {
longitude: width >= 360 - EPSILON
? 0
: normalizeLongitudeDegrees(bounds.west + width / 2),
latitude: (bounds.south + bounds.north) / 2,
};
}
export function graticuleSectorCenter(address, stepDegrees) {
return geodeticBoundsCenter(graticuleSectorBounds(address, stepDegrees));
}
export function graticuleSectorAreaSquareMeters(address, stepDegrees) {
return geodeticRectangleAreaSquareMeters(graticuleSectorBounds(address, stepDegrees));
}
function graticuleSectorDetail(address, stepDegrees) {
const bounds = graticuleSectorBounds(address, stepDegrees);
return {
address,
bounds,
center: graticuleSectorCenter(address, stepDegrees),
areaSquareMeters: graticuleSectorAreaSquareMeters(address, stepDegrees),
};
}
export function graticuleSectorSummary(address, definition) {
const bounds = graticuleSectorBounds(address, definition.stepDegrees);
const neighbors = Object.fromEntries(
Object.entries(graticuleSectorNeighbors(address, definition))
.map(([direction, neighbor]) => [
direction,
neighbor == null ? null : graticuleSectorDetail(neighbor, definition.stepDegrees),
]),
);
const majorTile = definition?.majorStepDegrees == null
? null
: graticuleMajorTileSummary(graticuleMajorTileForSector(address, definition), definition);
return {
id: address.id,
address,
family: "wgs84-graticule",
lod: address.lod,
label: `L${address.lod} X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`,
indices: {
longitudeIndex: address.longitudeIndex,
latitudeIndex: address.latitudeIndex,
},
bounds,
center: graticuleSectorCenter(address, definition.stepDegrees),
areaSquareMeters: graticuleSectorAreaSquareMeters(address, definition.stepDegrees),
neighbors,
majorTile,
parentMajorTile: majorTile,
};
}
export function graticuleMajorTileCenter(address, majorStepDegrees) {
return geodeticBoundsCenter(graticuleMajorTileBounds(address, majorStepDegrees));
}
export function graticuleMajorTileAreaSquareMeters(address, majorStepDegrees) {
return geodeticRectangleAreaSquareMeters(graticuleMajorTileBounds(address, majorStepDegrees));
}
function graticuleMajorTileDetail(address, majorStepDegrees) {
const bounds = graticuleMajorTileBounds(address, majorStepDegrees);
return {
address,
bounds,
center: graticuleMajorTileCenter(address, majorStepDegrees),
areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, majorStepDegrees),
};
}
export function graticuleMajorTileSummary(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
const bounds = graticuleMajorTileBounds(address, metrics.majorStepDegrees);
const neighbors = Object.fromEntries(
Object.entries(graticuleMajorTileNeighbors(address, definition))
.map(([direction, neighbor]) => [
direction,
neighbor == null ? null : graticuleMajorTileDetail(neighbor, metrics.majorStepDegrees),
]),
);
return {
id: address.id,
address,
family: "wgs84-graticule-major",
lod: address.lod,
label: `L${address.lod} TILE X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`,
indices: {
longitudeIndex: address.longitudeIndex,
latitudeIndex: address.latitudeIndex,
},
minorPerSide: metrics.minorPerMajor,
childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"),
bounds,
center: graticuleMajorTileCenter(address, metrics.majorStepDegrees),
areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, metrics.majorStepDegrees),
neighbors,
};
}
export function splitLongitudeRange(west, east) {
const rawWest = finite(west, -180);
const rawEast = finite(east, 180);
+69
View File
@@ -983,6 +983,75 @@ textarea {
text-align: right;
}
.catalog-map-grid-sector {
display: grid;
min-width: 0;
gap: 0.55rem;
border-top: 1px solid color-mix(in srgb, var(--nodedc-glass-outline) 44%, transparent);
padding-top: 0.7rem;
}
.catalog-map-grid-sector__facts,
.catalog-map-grid-sector__volume {
display: grid;
min-width: 0;
gap: 0.35rem;
}
.catalog-map-grid-sector__facts .nodedc-control-row__control,
.catalog-map-grid-sector__volume .nodedc-control-row__control {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
}
.catalog-map-grid-sector__relation,
.catalog-map-grid-sector__neighbor,
.catalog-map-grid-sector__volume {
display: grid;
min-width: 0;
gap: 0.4rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-glass-control-bg);
padding: 0.62rem;
}
.catalog-map-grid-sector__relation > small,
.catalog-map-grid-sector__neighbor > code,
.catalog-map-grid-sector__volume > code {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
}
.catalog-map-grid-sector__relation > code,
.catalog-map-grid-sector__neighbor > code,
.catalog-map-grid-sector__volume > code {
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
}
.catalog-map-grid-sector__neighbors {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.catalog-map-grid-sector__volume-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.4rem;
}
.catalog-map-grid-sector__neighbor {
align-content: start;
}
.catalog-map-grid-sector__volume[data-enabled] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 28%, transparent);
}
.catalog-map-subject-card__tab-panel {
display: grid;
gap: 0.65rem;
+22 -1
View File
@@ -4,7 +4,7 @@
Область: Module Foundry Map Page, Platform Map Gateway, общий TileCache, DC AMD Proxy и DC AMD Connector
Контрольная дата: 2026-07-16
Контрольная дата: 2026-08-06
Этот документ — воспроизводимый канон интеграции Cesium в NODE.DC. Он описывает не только текущий Foundry Map Page, но и обязательные границы для следующих продуктов NODE.DC, которым потребуются Cesium Terrain, imagery, 3D Tiles или другие разрешённые provider assets.
@@ -181,6 +181,27 @@ Per-entity `PolygonGraphics` для массового слоя геозон н
а viewport через антимеридиан разбивается на два диапазона;
- sector id включает origin, LOD, шаг и signed integer address. Он не содержит
render serial, viewport или camera state и сохраняется при pan/zoom/reload;
- каждый minor-сектор имеет вычисляемые bounds, центр, площадь и четыре
адресных соседа. Для WGS84 longitude-соседи замыкаются через антимеридиан,
а за северным и южным полюсом сосед отсутствует;
- local ENU hierarchy использует `tileSizeKm` как размер major-тайла. Major
содержит целое число minor-секторов по каждой стороне; изменение шага в UI
сохраняет это соотношение. Для WGS84 major-шаг равен пяти minor-шагам;
- выбранный minor-сектор показывает parent major, число его children и
позволяет центрировать parent, переходить к четырём соседям и совместимому
следующему LOD без перебора Cesium entities. Полный список children не
материализуется автоматически: чистый API выдаёт его ограниченными pages;
горизонтальный sector id при этом не меняется;
- LOD 1–2 по умолчанию поддерживают отдельный высотный адрес ENU-объёма.
Диапазон задаётся как полуоткрытый `[minimum, maximum)`, делится на bands, а
последний band может быть короче. Volume id является дочерним адресом и не
подменяет стабильный id горизонтального сектора;
- Cesium материализует пространственную клетку только для выбранного сектора:
нижнюю и верхнюю рамки, вертикальные рёбра и границы bands. Вся ENU-плоскость
не размножается в объёмные entities, поэтому стоимость выбора ограничена;
- minor и major линии собираются раздельно. Major получает только множитель
толщины, наследует цвет/прозрачность текущего LOD, а подписи создаются
ограниченным набором вокруг viewport и не являются источником адресации;
- camera может менять LOD, view-cone и набор видимых линий, но не координаты,
границы или идентификаторы секторов;
- новый buffer подключается до удаления предыдущего. Дешёвый plan key
+1 -1
View File
@@ -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 scripts/map-sector-grid.test.mjs",
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.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",
+5 -5
View File
@@ -75,11 +75,11 @@
"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 }
{ "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, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 300, "volumeBandHeightMeters": 300 },
{ "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, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 },
{ "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, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 },
{ "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, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 },
{ "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, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 }
]
},
"mapHeight": 620,
+35 -1
View File
@@ -23,7 +23,17 @@ const settings = {
};
test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD donor profile", () => {
assert.deepEqual(DEFAULT_GRID_LOD_PROFILES, [
const donorProfiles = DEFAULT_GRID_LOD_PROFILES.map(({
majorLinesEnabled: _majorLinesEnabled,
majorLabelsEnabled: _majorLabelsEnabled,
majorLineWidthMultiplier: _majorLineWidthMultiplier,
volumeEnabled: _volumeEnabled,
volumeMinimumHeightMeters: _volumeMinimumHeightMeters,
volumeMaximumHeightMeters: _volumeMaximumHeightMeters,
volumeBandHeightMeters: _volumeBandHeightMeters,
...profile
}) => profile);
assert.deepEqual(donorProfiles, [
{
maxHeightKm: 10,
stepKm: 1,
@@ -150,6 +160,21 @@ test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD do
graticuleOpacity: 8,
},
]);
assert.deepEqual(DEFAULT_GRID_LOD_PROFILES.map((profile) => ({
majorLinesEnabled: profile.majorLinesEnabled,
majorLabelsEnabled: profile.majorLabelsEnabled,
majorLineWidthMultiplier: profile.majorLineWidthMultiplier,
volumeEnabled: profile.volumeEnabled,
volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters,
volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters,
volumeBandHeightMeters: profile.volumeBandHeightMeters,
})), [
{ majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300 },
{ majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 },
{ majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 },
{ majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 },
{ majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 },
]);
});
test("LOD selection keeps three spatial bands, two graticule bands and the independent 10,000 km cutoff", () => {
@@ -264,6 +289,15 @@ test("renderer uses fixed ENU sectors, angular graticules and a non-blank double
assert.match(source, /lod\.graticuleLineWidthPx/);
assert.match(source, /arcType: ArcType\.RHUMB/);
assert.match(source, /clampToGround: false/);
assert.match(source, /majorLineWidthMultiplier/);
assert.match(source, /isLocalMajorLineIndex/);
assert.match(source, /isGraticuleMajorLineValue/);
assert.match(source, /new LabelCollection\(\)/);
assert.match(source, /const maximumLabels = 48/);
assert.match(source, /materializeLocalSelection/);
assert.match(source, /localVolumeAt\(/);
assert.match(source, /focusGridSector/);
assert.match(source, /selectedGridSector/);
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/);
+406
View File
@@ -12,16 +12,44 @@ import {
alignedGridValues,
boundedAngularParts,
fixedGridOrigin,
geodeticRectangleAreaSquareMeters,
graticuleGranularity,
graticuleLinePlan,
graticuleMajorTileAt,
graticuleMajorTileBounds,
graticuleMajorTileChildren,
graticuleMajorTileForSector,
graticuleMajorTileNeighbors,
graticuleMajorTileSummary,
graticuleSectorAreaSquareMeters,
graticuleSectorAt,
graticuleSectorBounds,
graticuleSectorNeighbors,
graticuleSectorSummary,
isGraticuleMajorLineIndex,
isGraticuleMajorLineValue,
isLocalMajorLineIndex,
localGridPlan,
localMajorTileAt,
localMajorTileBounds,
localMajorTileChildren,
localMajorTileForSector,
localMajorTileId,
localMajorTileNeighbors,
localMajorTileSummary,
MAX_GRID_CHILD_PAGE_SIZE,
MAX_LOCAL_GRID_INDEX,
localParentSector,
localSectorAreaSquareMeters,
localSectorAt,
localSectorBounds,
localSectorNeighbors,
localSectorSummary,
localVolumeAt,
localVolumeBounds,
localVolumeId,
localVolumeNeighbors,
localVolumeSummary,
normalizeLongitudeDegrees,
splitLongitudeRange,
} from "../apps/catalog/src/mapSectorGrid.mjs";
@@ -33,6 +61,18 @@ const localDefinition = {
stepMeters: 1_000,
};
const localHierarchyDefinition = {
...localDefinition,
tileSizeMeters: 10_000,
};
const volumeDefinition = {
...localDefinition,
altitudeFloorMeters: -50,
altitudeCeilingMeters: 225,
altitudeBandMeters: 100,
};
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 }), {
@@ -108,6 +148,230 @@ test("local sector neighbors and integer-ratio parents preserve signed addressin
);
});
test("local major tiles keep a fixed phase across positive and negative ENU boundaries", () => {
const samples = [
[-10_000.001, -2],
[-10_000, -1],
[-0.001, -1],
[0, 0],
[9_999.999, 0],
[10_000, 1],
];
assert.deepEqual(
samples.map(([eastMeters]) => localMajorTileAt(
{ eastMeters, northMeters: 9_999.999 },
localHierarchyDefinition,
).eastIndex),
samples.map(([, expected]) => expected),
);
const tile = localMajorTileAt(
{ eastMeters: -0.001, northMeters: 9_999.999 },
localHierarchyDefinition,
);
assert.equal(tile.id, "grid/local/55.755800,37.617300/l1/s1000.000/t10000.000/e-1/n+0");
assert.equal(tile.minorPerSide, 10);
assert.deepEqual(localMajorTileBounds(tile, localHierarchyDefinition.tileSizeMeters), {
west: -10_000,
east: 0,
south: 0,
north: 10_000,
});
});
test("local major parent, children and neighbors are exact inverse hierarchy operations", () => {
const tile = localMajorTileAt(
{ eastMeters: -1, northMeters: 1 },
localHierarchyDefinition,
);
const children = localMajorTileChildren(tile, localHierarchyDefinition);
assert.equal(children.length, 100);
assert.deepEqual(
[children[0].eastIndex, children[0].northIndex, children.at(-1).eastIndex, children.at(-1).northIndex],
[-10, 0, -1, 9],
);
for (const child of children) {
assert.equal(localMajorTileForSector(child, localHierarchyDefinition).id, tile.id);
}
const neighbors = localMajorTileNeighbors(tile, localHierarchyDefinition);
assert.deepEqual(
Object.fromEntries(Object.entries(neighbors).map(([direction, address]) => [
direction,
[address.eastIndex, address.northIndex],
])),
{ north: [-1, 1], east: [0, 0], south: [-1, -1], west: [-2, 0] },
);
assert.equal(isLocalMajorLineIndex(-10, localHierarchyDefinition), true);
assert.equal(isLocalMajorLineIndex(-1, localHierarchyDefinition), false);
assert.equal(isLocalMajorLineIndex(0, localHierarchyDefinition), true);
});
test("large local major tiles require bounded child pages", () => {
const largeDefinition = { ...localDefinition, stepMeters: 100, tileSizeMeters: 50_000 };
const tile = localMajorTileAt({ eastMeters: 0, northMeters: 0 }, largeDefinition);
assert.equal(tile.minorPerSide, 500);
assert.throws(
() => localMajorTileChildren(tile, largeDefinition),
/grid_child_page_limit_exceeded/,
);
const tail = localMajorTileChildren(tile, largeDefinition, { offset: 249_998, limit: 2 });
assert.deepEqual(tail.map(({ eastIndex, northIndex }) => [eastIndex, northIndex]), [
[498, 499],
[499, 499],
]);
assert.throws(
() => localMajorTileChildren(tile, largeDefinition, { limit: MAX_GRID_CHILD_PAGE_SIZE + 1 }),
/grid_child_page_limit_exceeded/,
);
});
test("local hierarchy rejects non-integral, non-positive and unsafe definitions", () => {
assert.throws(
() => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, {
...localDefinition,
tileSizeMeters: 2_500,
}),
/grid_tile_size_must_be_integer_multiple_of_step/,
);
assert.throws(
() => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, {
...localDefinition,
tileSizeMeters: 0,
}),
/grid_tile_size_must_be_positive/,
);
assert.throws(
() => localMajorTileAt({ eastMeters: 0, northMeters: 0 }, {
...localDefinition,
stepMeters: Number.NaN,
tileSizeMeters: 10_000,
}),
/grid_step_must_be_positive/,
);
assert.throws(
() => localMajorTileId(localHierarchyDefinition, 0.5, 0),
/grid_index_must_be_safe_integer/,
);
assert.doesNotThrow(() => localMajorTileAt(
{ eastMeters: -0.001, northMeters: 0 },
{ ...localDefinition, stepMeters: 0.1, tileSizeMeters: 1 },
));
});
test("local summaries are UI-ready and expose neighbor and major-parent geometry", () => {
const sector = localSectorAt({ eastMeters: -1, northMeters: 2_500 }, localDefinition);
const summary = localSectorSummary(sector, localHierarchyDefinition);
assert.equal(summary.id, sector.id);
assert.deepEqual(summary.center, { eastMeters: -500, northMeters: 2_500 });
assert.equal(summary.areaSquareMeters, 1_000_000);
assert.equal(localSectorAreaSquareMeters(sector, 1_000), 1_000_000);
assert.equal(summary.neighbors.west.address.id, localSectorNeighbors(sector, localDefinition).west.id);
assert.deepEqual(summary.neighbors.north.center, { eastMeters: -500, northMeters: 3_500 });
assert.equal(summary.majorTile?.id, summary.parentMajorTile?.id);
assert.equal(summary.majorTile?.minorPerSide, 10);
assert.deepEqual(summary.majorTile?.center, { eastMeters: -5_000, northMeters: 5_000 });
assert.equal(summary.majorTile?.areaSquareMeters, 100_000_000);
const standalone = localSectorSummary(sector, localDefinition);
assert.equal(standalone.majorTile, null);
assert.equal(standalone.parentMajorTile, null);
const majorSummary = localMajorTileSummary(summary.majorTile.address, localHierarchyDefinition);
assert.equal(majorSummary.childCount, 100);
assert.equal(majorSummary.neighbors.east.address.eastIndex, 0);
});
test("local volume addressing is half-open and clips an incomplete last band", () => {
const samples = [
[-50, 0],
[49.999, 0],
[50, 1],
[149.999, 1],
[150, 2],
[224.999, 2],
];
for (const [altitudeMeters, expectedBand] of samples) {
assert.equal(
localVolumeAt({ eastMeters: -1, northMeters: 2_500, altitudeMeters }, volumeDefinition)?.bandIndex,
expectedBand,
);
}
assert.equal(localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: -50.001 }, volumeDefinition), null);
assert.equal(localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 225 }, volumeDefinition), null);
const last = localVolumeAt({ eastMeters: -1, northMeters: 2_500, altitudeMeters: 200 }, volumeDefinition);
assert.ok(last);
assert.equal(last.id, "grid/local-volume/55.755800,37.617300/l1/s1000.000/f-50.000/c225.000/h100.000/e-1/n+2/z+2");
assert.equal(last.altitudeBandMeters, 75);
assert.deepEqual(localVolumeBounds(last, volumeDefinition), {
west: -1_000,
east: 0,
south: 2_000,
north: 3_000,
altitudeFloorMeters: 150,
altitudeCeilingMeters: 225,
});
const summary = localVolumeSummary(last, volumeDefinition);
assert.equal(summary.address.id, last.id);
assert.deepEqual(summary.center, {
eastMeters: -500,
northMeters: 2_500,
altitudeMeters: 187.5,
});
assert.equal(summary.footprintAreaSquareMeters, 1_000_000);
assert.equal(summary.volumeCubicMeters, 75_000_000);
});
test("local volume neighbors stop at vertical limits but remain unbounded horizontally", () => {
const first = localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, volumeDefinition);
assert.ok(first);
const firstNeighbors = localVolumeNeighbors(first, volumeDefinition);
assert.equal(firstNeighbors.below, null);
assert.equal(firstNeighbors.above?.bandIndex, 1);
assert.equal(firstNeighbors.west.eastIndex, -1);
const last = localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 200 }, volumeDefinition);
assert.ok(last);
const lastNeighbors = localVolumeNeighbors(last, volumeDefinition);
assert.equal(lastNeighbors.above, null);
assert.equal(lastNeighbors.below?.bandIndex, 1);
});
test("local volume contract rejects degenerate ranges, bands and invalid addresses", () => {
assert.throws(
() => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, {
...volumeDefinition,
altitudeBandMeters: 0,
}),
/grid_altitude_band_must_be_positive/,
);
assert.throws(
() => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, {
...volumeDefinition,
altitudeCeilingMeters: -50,
}),
/grid_altitude_ceiling_must_exceed_floor/,
);
assert.throws(
() => localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: 0 }, {
...volumeDefinition,
altitudeFloorMeters: Number.NaN,
}),
/grid_altitude_floor_must_be_finite/,
);
assert.equal(
localVolumeAt({ eastMeters: 0, northMeters: 0, altitudeMeters: Number.NaN }, volumeDefinition),
null,
);
assert.throws(
() => localVolumeId(volumeDefinition, 0, 0, -1),
/grid_altitude_band_index_out_of_range/,
);
assert.throws(
() => localVolumeId(volumeDefinition, 0, 0, 3),
/grid_altitude_band_index_out_of_range/,
);
});
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]), [
@@ -229,3 +493,145 @@ test("graticule sector IDs, negative boundaries and bounds use the same global p
});
assert.equal(seamEast.id, seamWest.id);
});
test("graticule minor neighbors wrap at the date line and stop at both poles", () => {
const definition = { lod: 4, stepDegrees: 2 };
const northEast = graticuleSectorAt({ longitude: 179.999, latitude: 89.999 }, definition);
const northEastNeighbors = graticuleSectorNeighbors(northEast, definition);
assert.equal(northEastNeighbors.north, null);
assert.deepEqual(
[northEastNeighbors.east.longitudeIndex, northEastNeighbors.east.latitudeIndex],
[-90, 44],
);
assert.deepEqual(
[northEastNeighbors.west.longitudeIndex, northEastNeighbors.west.latitudeIndex],
[88, 44],
);
const southWest = graticuleSectorAt({ longitude: -180, latitude: -90 }, definition);
const southWestNeighbors = graticuleSectorNeighbors(southWest, definition);
assert.equal(southWestNeighbors.south, null);
assert.equal(southWestNeighbors.west.longitudeIndex, 89);
const unevenDefinition = { lod: 4, stepDegrees: 7 };
const unevenWest = graticuleSectorAt({ longitude: -180, latitude: 0 }, unevenDefinition);
const unevenNeighbors = graticuleSectorNeighbors(unevenWest, unevenDefinition);
assert.equal(unevenNeighbors.west.longitudeIndex, 25);
assert.throws(
() => graticuleSectorNeighbors({ ...unevenWest, latitudeIndex: 13 }, unevenDefinition),
/grid_graticule_latitude_index_out_of_range/,
);
});
test("graticule major hierarchy has stable IDs, exact children and seam-safe neighbors", () => {
const definition = { lod: 4, stepDegrees: 2, majorStepDegrees: 10 };
const tile = graticuleMajorTileAt({ longitude: -0.001, latitude: 9.999 }, definition);
assert.equal(tile.id, "grid/wgs84/l4/s2.000000/m10.000000/x-1/y+0");
assert.equal(tile.minorPerSide, 5);
assert.deepEqual(graticuleMajorTileBounds(tile, definition.majorStepDegrees), {
west: -10,
east: 0,
south: 0,
north: 10,
});
const children = graticuleMajorTileChildren(tile, definition);
assert.equal(children.length, 25);
assert.deepEqual(
[children[0].longitudeIndex, children[0].latitudeIndex, children.at(-1).longitudeIndex, children.at(-1).latitudeIndex],
[-5, 0, -1, 4],
);
for (const child of children) {
assert.equal(graticuleMajorTileForSector(child, definition).id, tile.id);
}
const seam = graticuleMajorTileAt({ longitude: 179.999, latitude: 89.999 }, definition);
const seamNeighbors = graticuleMajorTileNeighbors(seam, definition);
assert.equal(seam.longitudeIndex, 17);
assert.equal(seam.latitudeIndex, 8);
assert.equal(seamNeighbors.north, null);
assert.equal(seamNeighbors.east.longitudeIndex, -18);
assert.equal(graticuleMajorTileAt({ longitude: 180, latitude: 0 }, definition).longitudeIndex, -18);
assert.equal(graticuleMajorTileAt({ longitude: -180, latitude: 0 }, definition).id,
graticuleMajorTileAt({ longitude: 180, latitude: 0 }, definition).id);
assert.equal(
graticuleMajorTileAt({ longitude: -180, latitude: 0 }, definition).id,
graticuleMajorTileAt({ longitude: 540, latitude: 0 }, definition).id,
);
assert.equal(isGraticuleMajorLineIndex(-5, definition), true);
assert.equal(isGraticuleMajorLineIndex(-4, definition), false);
assert.equal(isGraticuleMajorLineValue(-10, definition), true);
assert.equal(isGraticuleMajorLineValue(-9.999, definition), false);
});
test("large graticule major tiles expose children through bounded pages", () => {
const definition = { lod: 4, stepDegrees: 0.1, majorStepDegrees: 90 };
const tile = graticuleMajorTileAt({ longitude: 0, latitude: 0 }, definition);
assert.equal(tile.minorPerSide, 900);
assert.throws(
() => graticuleMajorTileChildren(tile, definition),
/grid_child_page_limit_exceeded/,
);
const tail = graticuleMajorTileChildren(tile, definition, { offset: 809_999, limit: 1 });
assert.deepEqual(
[tail[0].longitudeIndex, tail[0].latitudeIndex],
[899, 899],
);
});
test("graticule hierarchy rejects ambiguous global partitions and malformed indices", () => {
assert.throws(
() => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, {
lod: 4,
stepDegrees: 2,
majorStepDegrees: 5,
}),
/grid_graticule_major_step_must_be_integer_multiple_of_step/,
);
assert.throws(
() => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, {
lod: 4,
stepDegrees: 1,
majorStepDegrees: 7,
}),
/grid_graticule_major_step_must_partition_hemisphere/,
);
assert.throws(
() => graticuleMajorTileAt({ longitude: 0, latitude: 0 }, {
lod: 4,
stepDegrees: 1,
majorStepDegrees: 0,
}),
/grid_graticule_major_step_must_be_positive/,
);
assert.throws(
() => graticuleMajorTileBounds({ longitudeIndex: 0, latitudeIndex: 9.5 }, 10),
/grid_index_must_be_safe_integer/,
);
});
test("WGS84 summaries expose ellipsoidal area, centers, neighbors and major parents", () => {
const definition = { lod: 4, stepDegrees: 2, majorStepDegrees: 10 };
const sector = graticuleSectorAt({ longitude: -0.001, latitude: -0.001 }, definition);
const summary = graticuleSectorSummary(sector, definition);
assert.deepEqual(summary.center, { longitude: -1, latitude: -1 });
assert.equal(summary.address.id, sector.id);
assert.equal(summary.neighbors.east.address.id,
graticuleSectorAt({ longitude: 0, latitude: -0.001 }, definition).id);
assert.equal(summary.majorTile?.id, summary.parentMajorTile?.id);
assert.deepEqual(summary.majorTile?.bounds, { west: -10, east: 0, south: -10, north: 0 });
assert.equal(summary.majorTile?.childCount, 25);
assert.ok(summary.areaSquareMeters > 49_000_000_000 && summary.areaSquareMeters < 50_000_000_000);
const equator = graticuleSectorAt({ longitude: 0, latitude: 0 }, definition);
const polar = graticuleSectorAt({ longitude: 0, latitude: 89 }, definition);
assert.ok(
graticuleSectorAreaSquareMeters(equator, definition.stepDegrees)
> graticuleSectorAreaSquareMeters(polar, definition.stepDegrees) * 20,
);
const worldArea = geodeticRectangleAreaSquareMeters({ west: -180, east: 180, south: -90, north: 90 });
assert.ok(worldArea > 5.10e14 && worldArea < 5.11e14);
const majorSummary = graticuleMajorTileSummary(summary.majorTile.address, definition);
assert.equal(majorSummary.neighbors.east.address.longitudeIndex, 0);
assert.equal(graticuleSectorSummary(sector, { lod: 4, stepDegrees: 2 }).majorTile, null);
});
+103 -11
View File
@@ -536,7 +536,8 @@ const GRID_LOD_PROFILE_KEYS = new Set([
"lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters",
"dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters",
"crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor",
"graticuleOpacity",
"graticuleOpacity", "majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier",
"volumeEnabled", "volumeMinimumHeightMeters", "volumeMaximumHeightMeters", "volumeBandHeightMeters",
]);
const GRID_LOD_PROFILE_INPUT_KEYS = new Set([...GRID_LOD_PROFILE_KEYS, "lineWidthPx"]);
@@ -561,6 +562,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300,
}),
Object.freeze({
maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -568,6 +571,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -575,6 +580,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -582,6 +589,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 3_000, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -589,14 +598,30 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
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,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
]);
function promoteLegacyGridLodProfiles(settings) {
return DEFAULT_GRID_LOD_PROFILES.map((fallback, index) => {
const number = index + 1;
// Keep migration provenance: sector-v2 extension fields must stay absent
// until validateGridLodProfiles applies mode-aware defaults. Spreading the
// new fallback here would make an old flat payload look explicit and
// could reject it for a hierarchy it never configured.
const {
majorLinesEnabled: _majorLinesEnabled,
majorLabelsEnabled: _majorLabelsEnabled,
majorLineWidthMultiplier: _majorLineWidthMultiplier,
volumeEnabled: _volumeEnabled,
volumeMinimumHeightMeters: _volumeMinimumHeightMeters,
volumeMaximumHeightMeters: _volumeMaximumHeightMeters,
volumeBandHeightMeters: _volumeBandHeightMeters,
...legacyFallback
} = fallback;
return {
...fallback,
...legacyFallback,
maxHeightKm: settings[`gridLod${number}MaxHeightKm`] ?? fallback.maxHeightKm,
stepKm: settings[`gridLod${number}StepKm`] ?? fallback.stepKm,
mode: settings[`gridLod${number}Mode`] ?? fallback.mode,
@@ -631,13 +656,30 @@ function validateGridLodProfiles(value) {
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`);
}
const legacyProfile = Object.hasOwn(profile, "lineWidthPx");
const normalized = legacyProfile ? {
const normalized = {
...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 };
...(legacyProfile ? {
graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees,
graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx,
graticuleColor: profile.graticuleColor ?? profile.lineColor,
graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity,
} : {}),
majorLinesEnabled: profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled,
majorLabelsEnabled: profile.majorLabelsEnabled ?? (
(profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled)
? DEFAULT_GRID_LOD_PROFILES[index].majorLabelsEnabled
: false
),
majorLineWidthMultiplier: profile.majorLineWidthMultiplier ?? DEFAULT_GRID_LOD_PROFILES[index].majorLineWidthMultiplier,
volumeEnabled: profile.volumeEnabled ?? (
(profile.mode ?? DEFAULT_GRID_LOD_PROFILES[index].mode) === "3d"
? DEFAULT_GRID_LOD_PROFILES[index].volumeEnabled
: false
),
volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMinimumHeightMeters,
volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMaximumHeightMeters,
volumeBandHeightMeters: profile.volumeBandHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeBandHeightMeters,
};
delete normalized.lineWidthPx;
if (GRID_LOD_PROFILE_KEYS.size !== Object.keys(normalized).length
|| [...GRID_LOD_PROFILE_KEYS].some((key) => !Object.hasOwn(normalized, key))) {
@@ -649,19 +691,62 @@ function validateGridLodProfiles(value) {
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_maxHeightKm_order`);
}
previousMaxHeightKm = maxHeightKm;
if (!['3d', 'graticule'].includes(normalized.mode)) throw applicationError(code("mode"));
if (!["3d", "graticule"].includes(normalized.mode)) throw applicationError(code("mode"));
const stepKm = requireNumber(normalized.stepKm, 0.1, 5_000, code("stepKm"));
const tileSizeKm = requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm"));
const graticuleStepDegrees = requireNumber(
normalized.graticuleStepDegrees,
0.1,
180,
code("graticuleStepDegrees"),
);
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`);
}
const hasExplicitMajorPolicy = ["majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier"]
.some((key) => Object.hasOwn(profile, key));
let majorLinesEnabled = requireBoolean(normalized.majorLinesEnabled, code("majorLinesEnabled"));
let majorLabelsEnabled = requireBoolean(normalized.majorLabelsEnabled, code("majorLabelsEnabled"));
if (majorLabelsEnabled && !majorLinesEnabled) {
throw applicationError(code("majorLabelsEnabled_requires_majorLinesEnabled"));
}
if (normalized.mode === "3d" && majorLinesEnabled) {
const minorPerMajor = tileSizeKm / stepKm;
if (minorPerMajor < 1 || Math.abs(minorPerMajor - Math.round(minorPerMajor))
> 1e-9 * Math.max(1, Math.abs(minorPerMajor))) {
if (hasExplicitMajorPolicy) throw applicationError(code("tileSizeKm_step_ratio"));
majorLinesEnabled = false;
majorLabelsEnabled = false;
}
}
if (normalized.mode === "graticule" && majorLinesEnabled) {
const majorLatitudeHemisphereSteps = 90 / (graticuleStepDegrees * 5);
if (Math.abs(majorLatitudeHemisphereSteps - Math.round(majorLatitudeHemisphereSteps))
> 1e-9 * Math.max(1, Math.abs(majorLatitudeHemisphereSteps))) {
if (hasExplicitMajorPolicy) throw applicationError(code("graticuleStepDegrees_major_partition"));
majorLinesEnabled = false;
majorLabelsEnabled = false;
}
}
const volumeEnabled = requireBoolean(normalized.volumeEnabled, code("volumeEnabled"));
if (volumeEnabled && normalized.mode !== "3d") {
throw applicationError(code("volumeEnabled_mode"));
}
const volumeMinimumHeightMeters = requireNumber(normalized.volumeMinimumHeightMeters, -1_000, 10_000, code("volumeMinimumHeightMeters"));
const volumeMaximumHeightMeters = requireNumber(normalized.volumeMaximumHeightMeters, -1_000, 10_000, code("volumeMaximumHeightMeters"));
const volumeBandHeightMeters = requireNumber(normalized.volumeBandHeightMeters, 1, 10_000, code("volumeBandHeightMeters"));
if (volumeMaximumHeightMeters <= volumeMinimumHeightMeters) throw applicationError(code("volumeHeightOrder"));
if (volumeBandHeightMeters > volumeMaximumHeightMeters - volumeMinimumHeightMeters) {
throw applicationError(code("volumeBandHeightMeters_span"));
}
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")),
tileSizeKm,
radiusKm,
lineDiameterMeters: requireNumber(normalized.lineDiameterMeters, 1, 100, code("lineDiameterMeters")),
lineColor: requireHex(normalized.lineColor, code("lineColor")),
@@ -675,10 +760,17 @@ function validateGridLodProfiles(value) {
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")),
graticuleStepDegrees,
graticuleLineWidthPx: requireNumber(normalized.graticuleLineWidthPx, 1, 3, code("graticuleLineWidthPx")),
graticuleColor: requireHex(normalized.graticuleColor, code("graticuleColor")),
graticuleOpacity: requireNumber(normalized.graticuleOpacity, 0, 100, code("graticuleOpacity")),
majorLinesEnabled,
majorLabelsEnabled,
majorLineWidthMultiplier: requireNumber(normalized.majorLineWidthMultiplier, 1, 8, code("majorLineWidthMultiplier")),
volumeEnabled,
volumeMinimumHeightMeters,
volumeMaximumHeightMeters,
volumeBandHeightMeters,
};
});
}
+7
View File
@@ -485,6 +485,13 @@ const mapPageSettingsPatchInputSchema = {
graticuleLineWidthPx: { type: "number", minimum: 1, maximum: 3 },
graticuleColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
graticuleOpacity: { type: "number", minimum: 0, maximum: 100 },
majorLinesEnabled: { type: "boolean" },
majorLabelsEnabled: { type: "boolean" },
majorLineWidthMultiplier: { type: "number", minimum: 1, maximum: 8 },
volumeEnabled: { type: "boolean" },
volumeMinimumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 },
volumeMaximumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 },
volumeBandHeightMeters: { type: "number", minimum: 1, maximum: 10000 },
},
},
},
+98
View File
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { DEFAULT_GRID_LOD_PROFILES } from "../apps/catalog/src/mapGridPolicy.mjs";
const seed = JSON.parse(await readFile(new URL("../runtime-seed/page-layouts/map.json", import.meta.url), "utf8"));
const serverSource = await readFile(new URL("./catalog-server.mjs", import.meta.url), "utf8");
const mcpSource = await readFile(new URL("./foundry-mcp.mjs", import.meta.url), "utf8");
const extensionKeys = [
"majorLinesEnabled",
"majorLabelsEnabled",
"majorLineWidthMultiplier",
"volumeEnabled",
"volumeMinimumHeightMeters",
"volumeMaximumHeightMeters",
"volumeBandHeightMeters",
];
function applicationError(code, statusCode = 400) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
const validators = {
applicationError,
isObject: (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value),
requireNumber(value, minimum, maximum, code) {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
throw applicationError(code);
}
return value;
},
requireBoolean(value, code) {
if (typeof value !== "boolean") throw applicationError(code);
return value;
},
requireHex(value, code) {
const normalized = String(value || "");
if (!/^#[0-9a-f]{6}$/i.test(normalized)) throw applicationError(code);
return normalized.toLowerCase();
},
};
function loadServerGridContract() {
const start = serverSource.indexOf("const GRID_LOD_PROFILE_KEYS");
const end = serverSource.indexOf("function validateMapPageSettingsPatch");
assert.ok(start >= 0 && end > start, "server grid contract source boundaries");
const context = vm.createContext({ ...validators });
const source = `${serverSource.slice(start, end)}\n;globalThis.gridContract = { DEFAULT_GRID_LOD_PROFILES, promoteLegacyGridLodProfiles, validateGridLodProfiles };`;
new vm.Script(source, { filename: "catalog-server.grid-contract.mjs" }).runInContext(context);
return context.gridContract;
}
const serverGrid = loadServerGridContract();
test("seed, client defaults and server promotion keep the sector-v2 profile contract identical", () => {
assert.deepEqual(seed.settings.gridLodProfiles, DEFAULT_GRID_LOD_PROFILES);
const legacyProfiles = seed.settings.gridLodProfiles.map((profile) => {
const legacy = { ...profile };
for (const key of extensionKeys) delete legacy[key];
return legacy;
});
assert.deepEqual(
structuredClone(serverGrid.validateGridLodProfiles(legacyProfiles)),
DEFAULT_GRID_LOD_PROFILES,
);
});
test("flat layouts migrate mode-aware extensions while explicit invalid hierarchy is rejected", () => {
const flatSettings = {
...seed.settings,
gridLodProfiles: undefined,
gridLod1StepKm: 3,
gridTileSizeKm: 10,
gridLod2Mode: "graticule",
};
const migrated = structuredClone(serverGrid.validateGridLodProfiles(
serverGrid.promoteLegacyGridLodProfiles(flatSettings),
));
assert.equal(migrated[0].majorLinesEnabled, false);
assert.equal(migrated[0].majorLabelsEnabled, false);
assert.equal(migrated[1].mode, "graticule");
assert.equal(migrated[1].volumeEnabled, false);
const invalidExplicit = structuredClone(DEFAULT_GRID_LOD_PROFILES);
invalidExplicit[0].stepKm = 3;
invalidExplicit[0].tileSizeKm = 10;
assert.throws(
() => serverGrid.validateGridLodProfiles(invalidExplicit),
/invalid_map_grid_lod_profile_1_tileSizeKm_step_ratio/,
);
});
test("Foundry MCP exposes every persisted sector-v2 profile field", () => {
for (const key of extensionKeys) assert.match(mcpSource, new RegExp(`\\b${key}: \\{`));
});