feat(map): add functional sector grid v2
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}: \\{`));
|
||||
});
|
||||
Reference in New Issue
Block a user