feat(map): add functional sector grid v2

This commit is contained in:
Codex
2026-08-06 11:22:45 +03:00
parent 6f1fcb1eb0
commit d4827009d4
15 changed files with 2902 additions and 76 deletions
File diff suppressed because it is too large Load Diff
+505 -12
View File
@@ -43,7 +43,16 @@ import {
import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js";
import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs";
import { DEFAULT_GRID_LOD_PROFILES, gridLodProfile } from "./mapGridPolicy.mjs";
import { MAX_LOCAL_GRID_INDEX } from "./mapSectorGrid.mjs";
import {
MAX_LOCAL_GRID_INDEX,
graticuleSectorAt,
graticuleSectorSummary,
localSectorAt,
localSectorSummary,
localVolumeAt,
type GraticuleSectorAddress,
type LocalSectorAddress,
} from "./mapSectorGrid.mjs";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@@ -62,6 +71,239 @@ const GRID_MODE_OPTIONS: Array<SelectOption<"3d" | "graticule">> = [
{ value: "graticule", label: "Гратикула", description: "Проекция по поверхности" },
];
type SectorGridLodProfile = GridLodProfile & {
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
};
type GridSectorCopyState = "idle" | "copied" | "error";
const normalizedMajorTileSizeKm = (stepKm: number, requestedTileSizeKm: number) => {
const safeStepKm = Math.min(50, Math.max(0.1, stepKm));
const maximumRatio = Math.max(1, Math.floor((50 + Number.EPSILON) / safeStepKm));
const requestedRatio = Math.max(1, Math.ceil((requestedTileSizeKm - Number.EPSILON) / safeStepKm));
const ratio = Math.min(maximumRatio, requestedRatio);
return Number((safeStepKm * ratio).toFixed(6));
};
const normalizedGraticuleStepDegrees = (requestedStepDegrees: number) => {
const safeStepDegrees = Math.min(10, Math.max(0.1, requestedStepDegrees));
const requestedDivisions = Math.max(1, Math.round(180 / safeStepDegrees));
// Five minor intervals form one major tile and each 90° quadrant must end
// on a major boundary. A hemisphere therefore needs a multiple of ten
// minor intervals.
const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10);
return 180 / hemisphereDivisions;
};
const graticuleMajorStepDegrees = (stepDegrees: number) => {
const candidate = stepDegrees * 5;
const quadrantBands = 90 / candidate;
return Math.abs(quadrantBands - Math.round(quadrantBands)) <= 1e-9 * Math.max(1, Math.abs(quadrantBands))
? candidate
: null;
};
const normalizeSectorGridLodProfile = (profile: SectorGridLodProfile): SectorGridLodProfile => {
const stepKm = profile.mode === "3d" ? Math.min(50, profile.stepKm) : profile.stepKm;
const volumeMinimumHeightMeters = profile.volumeMinimumHeightMeters;
const volumeMaximumHeightMeters = Math.max(volumeMinimumHeightMeters + 1, profile.volumeMaximumHeightMeters);
return {
...profile,
stepKm,
tileSizeKm: profile.mode === "3d"
? normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, profile.tileSizeKm))
: profile.tileSizeKm,
graticuleStepDegrees: profile.mode === "graticule"
? normalizedGraticuleStepDegrees(profile.graticuleStepDegrees)
: profile.graticuleStepDegrees,
majorLabelsEnabled: profile.majorLinesEnabled && profile.majorLabelsEnabled,
volumeEnabled: profile.mode === "3d" && profile.volumeEnabled,
volumeMinimumHeightMeters,
volumeMaximumHeightMeters,
volumeBandHeightMeters: Math.min(
volumeMaximumHeightMeters - volumeMinimumHeightMeters,
Math.max(1, profile.volumeBandHeightMeters),
),
};
};
type GridSectorDirection = "north" | "east" | "south" | "west";
const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [
{ id: "north", label: "Север" },
{ id: "east", label: "Восток" },
{ id: "south", label: "Юг" },
{ id: "west", label: "Запад" },
];
function localGridSectorSelection(
address: LocalSectorAddress,
profile: SectorGridLodProfile,
origin: { latitude: number; longitude: number },
preferredAltitudeMeters?: number,
): GridSectorSelection {
const definition = {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
tileSizeMeters: profile.tileSizeKm * 1_000,
};
const summary = localSectorSummary(address, definition);
const volumeSpan = profile.volumeMaximumHeightMeters - profile.volumeMinimumHeightMeters;
const volume = profile.volumeEnabled && volumeSpan > 0
? (() => {
const altitudeMeters = Math.min(
profile.volumeMaximumHeightMeters - Number.EPSILON,
Math.max(
profile.volumeMinimumHeightMeters,
preferredAltitudeMeters ?? profile.volumeMinimumHeightMeters + Math.min(profile.volumeBandHeightMeters, volumeSpan) / 2,
),
);
const volumeAddress = localVolumeAt({ ...summary.center, altitudeMeters }, {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
altitudeFloorMeters: profile.volumeMinimumHeightMeters,
altitudeCeilingMeters: profile.volumeMaximumHeightMeters,
altitudeBandMeters: profile.volumeBandHeightMeters,
});
if (!volumeAddress) return null;
return {
id: volumeAddress.id,
index: volumeAddress.bandIndex,
floor: Math.max(profile.volumeMinimumHeightMeters, volumeAddress.altitudeFloorMeters),
ceiling: Math.min(profile.volumeMaximumHeightMeters, volumeAddress.altitudeCeilingMeters),
bandHeight: volumeAddress.altitudeBandMeters,
};
})()
: null;
return {
...summary,
mode: "3d",
address,
units: "meters-enu",
volume,
};
}
function graticuleGridSectorSelection(
address: GraticuleSectorAddress,
profile: SectorGridLodProfile,
): GridSectorSelection {
const majorStepDegrees = profile.majorLinesEnabled
? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined
: undefined;
const summary = graticuleSectorSummary(address, {
lod: address.lod,
stepDegrees: profile.graticuleStepDegrees,
majorStepDegrees,
});
return {
...summary,
mode: "graticule",
address,
units: "degrees-wgs84",
volume: null,
};
}
function gridSectorNeighborSelection(
selection: GridSectorSelection,
direction: GridSectorDirection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const profile = profiles[selection.lod - 1];
if (!profile) return null;
if (selection.mode === "3d") {
const neighbor = selection.neighbors[direction];
if (!neighbor) return null;
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters);
}
const neighbor = selection.neighbors[direction];
return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null;
}
function gridSectorParentLodSelection(
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const parentProfile = profiles[selection.lod];
if (!parentProfile || parentProfile.mode !== selection.mode) return null;
if (selection.mode === "3d") {
const address = localSectorAt(selection.center, {
lod: selection.lod + 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: parentProfile.stepKm * 1_000,
});
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return localGridSectorSelection(address, parentProfile, origin, preferredAltitudeMeters);
}
const address = graticuleSectorAt(selection.center, {
lod: selection.lod + 1,
stepDegrees: parentProfile.graticuleStepDegrees,
});
return graticuleGridSectorSelection(address, parentProfile);
}
function gridSectorVolumeNeighborSelection(
selection: GridSectorSelection,
direction: "above" | "below",
profile: SectorGridLodProfile | null,
origin: { latitude: number; longitude: number },
) {
if (selection.mode !== "3d" || !selection.volume || !profile?.volumeEnabled) return null;
const targetIndex = selection.volume.index + (direction === "above" ? 1 : -1);
const targetFloorMeters = profile.volumeMinimumHeightMeters + targetIndex * profile.volumeBandHeightMeters;
if (targetIndex < 0 || targetFloorMeters >= profile.volumeMaximumHeightMeters) return null;
const targetCeilingMeters = Math.min(
profile.volumeMaximumHeightMeters,
targetFloorMeters + profile.volumeBandHeightMeters,
);
return localGridSectorSelection(
selection.address,
profile,
origin,
(targetFloorMeters + targetCeilingMeters) / 2,
);
}
const formatGridMetric = (value: number, maximumFractionDigits = 1) => value.toLocaleString("ru-RU", {
maximumFractionDigits,
});
const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000
? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²`
: `${formatGridMetric(areaSquareMeters, 0)} м²`;
function gridSectorBoundsLabel(selection: GridSectorSelection) {
const { west, east, south, north } = selection.bounds;
return selection.mode === "3d"
? `E ${formatGridMetric(west)}${formatGridMetric(east)} м · N ${formatGridMetric(south)}${formatGridMetric(north)} м`
: `λ ${formatGridMetric(west, 6)}${formatGridMetric(east, 6)}° · φ ${formatGridMetric(south, 6)}${formatGridMetric(north, 6)}°`;
}
function gridSectorCenterLabel(selection: GridSectorSelection) {
return selection.mode === "3d"
? `E ${formatGridMetric(selection.center.eastMeters)} м · N ${formatGridMetric(selection.center.northMeters)} м`
: `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`;
}
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
order.nextEpoch += 1;
order.latestStartedEpoch = order.nextEpoch;
@@ -284,7 +526,9 @@ function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): GridLodPro
gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [],
cacheRefresh: false,
};
return Array.from({ length: 5 }, (_unused, index) => gridLodProfile(legacySettings, index) as GridLodProfile);
return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile(
gridLodProfile(legacySettings, index) as SectorGridLodProfile,
));
}
// A valid, deterministic scene view is available before Cesium emits its
@@ -411,6 +655,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const workspaceRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string>();
const [selectedGridSector, setSelectedGridSector] = useState<GridSectorSelection | null>(null);
const [gridSectorCopyState, setGridSectorCopyState] = useState<GridSectorCopyState>("idle");
const [subjectCardOpen, setSubjectCardOpen] = useState(false);
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
@@ -675,18 +920,75 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
);
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
const selectedGridLodIndex = Math.max(0, Math.min(4, Number.parseInt(selectedGridLod, 10) || 0));
const activeGridLod = mapSettings.gridLodProfiles[selectedGridLodIndex] ?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex];
const activeGridLod = (mapSettings.gridLodProfiles[selectedGridLodIndex]
?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex]) as SectorGridLodProfile;
const sectorGridLodProfiles = mapSettings.gridLodProfiles as SectorGridLodProfile[];
const gridSectorDefinitionKey = useMemo(() => JSON.stringify({
origin: [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude],
profiles: sectorGridLodProfiles.map((profile) => ({
mode: profile.mode,
stepKm: profile.stepKm,
tileSizeKm: profile.tileSizeKm,
graticuleStepDegrees: profile.graticuleStepDegrees,
majorLinesEnabled: profile.majorLinesEnabled,
volumeEnabled: profile.volumeEnabled,
volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters,
volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters,
volumeBandHeightMeters: profile.volumeBandHeightMeters,
})),
}), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude, sectorGridLodProfiles]);
const fixedSectorGridOrigin = useMemo(() => ({
latitude: mapSettings.gridCenterLatitude,
longitude: mapSettings.gridCenterLongitude,
}), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude]);
const selectedGridParentLod = useMemo(() => selectedGridSector
? gridSectorParentLodSelection(selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin)
: null, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]);
const selectedGridNeighborTargets = useMemo(() => Object.fromEntries(
GRID_SECTOR_DIRECTIONS.map(({ id }) => [id, selectedGridSector
? gridSectorNeighborSelection(selectedGridSector, id, sectorGridLodProfiles, fixedSectorGridOrigin)
: null]),
) as Record<GridSectorDirection, GridSectorSelection | null>, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]);
const selectedGridSectorProfile = selectedGridSector
? sectorGridLodProfiles[selectedGridSector.lod - 1] ?? null
: null;
const selectedGridVolumeTargets = useMemo(() => ({
above: selectedGridSector
? gridSectorVolumeNeighborSelection(selectedGridSector, "above", selectedGridSectorProfile, fixedSectorGridOrigin)
: null,
below: selectedGridSector
? gridSectorVolumeNeighborSelection(selectedGridSector, "below", selectedGridSectorProfile, fixedSectorGridOrigin)
: null,
}), [fixedSectorGridOrigin, selectedGridSector, selectedGridSectorProfile]);
const activeGraticuleMajorStepDegrees = activeGridLod.mode === "graticule"
? graticuleMajorStepDegrees(activeGridLod.graticuleStepDegrees)
: null;
useEffect(() => {
setSelectedGridSector(null);
}, [gridSectorDefinitionKey]);
const minimumGridLodHeight = selectedGridLodIndex === 0
? 0.1
: mapSettings.gridLodProfiles[selectedGridLodIndex - 1].maxHeightKm + 0.1;
const maximumGridLodHeight = selectedGridLodIndex === mapSettings.gridLodProfiles.length - 1
? 20_000
: Math.max(minimumGridLodHeight, mapSettings.gridLodProfiles[selectedGridLodIndex + 1].maxHeightKm - 0.1);
const updateGridLod = (patch: Partial<GridLodProfile>) => updateMapSettings({
const updateGridLod = (patch: Partial<SectorGridLodProfile>) => updateMapSettings({
gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => (
index === selectedGridLodIndex ? { ...profile, ...patch } : profile
)),
});
const updateGridVolumeRange = (patch: Partial<Pick<SectorGridLodProfile,
"volumeMinimumHeightMeters" | "volumeMaximumHeightMeters" | "volumeBandHeightMeters">>) => {
const minimum = patch.volumeMinimumHeightMeters ?? activeGridLod.volumeMinimumHeightMeters;
const maximum = Math.max(minimum + 1, patch.volumeMaximumHeightMeters ?? activeGridLod.volumeMaximumHeightMeters);
const span = maximum - minimum;
updateGridLod({
volumeMinimumHeightMeters: minimum,
volumeMaximumHeightMeters: maximum,
volumeBandHeightMeters: Math.min(span, Math.max(1, patch.volumeBandHeightMeters ?? activeGridLod.volumeBandHeightMeters)),
});
};
const setCacheEnabled = (cacheEnabled: boolean) => {
updateMapSettings({ cacheEnabled });
setRendererRevision((value) => value + 1);
@@ -711,6 +1013,29 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
setMapCamera(camera);
}, []);
useEffect(() => {
setGridSectorCopyState("idle");
}, [selectedGridSector?.id]);
const copySelectedGridSectorId = useCallback(async () => {
if (!selectedGridSector) return;
try {
await navigator.clipboard.writeText(selectedGridSector.id);
setGridSectorCopyState("copied");
} catch {
setGridSectorCopyState("error");
}
}, [selectedGridSector]);
const focusGridSector = useCallback((sector: GridSectorSelection | null) => {
if (!sector || !mapRendererRef.current?.focusGridSector(sector)) return;
setSelectedGridSector(sector);
}, []);
const focusGridMajorTile = useCallback((tile: NonNullable<GridSectorSelection["parentMajorTile"]>) => {
mapRendererRef.current?.focusGridMajorTile(tile);
}, []);
const handleSpiralStateChange = useCallback((state: CameraSpiralState) => {
setSpiralRunning(state.running);
setSpiralMessage(state.running ? null : spiralStopMessage(state.reason));
@@ -1323,11 +1648,60 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
</div>
<RangeControl label={selectedGridLodIndex === 4 ? "Порог профиля" : "До высоты"} value={activeGridLod.maxHeightKm} min={minimumGridLodHeight} max={maximumGridLodHeight} step={0.1} formatValue={(value) => `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} />
{selectedGridLodIndex === 4 ? <small className="catalog-map-inspector__note">Последний LOD остаётся активным выше своего порога до общего автовыключения.</small> : null}
<InspectorSelectField label="Режим" value={activeGridLod.mode} options={GRID_MODE_OPTIONS} onChange={(mode) => updateGridLod({ mode })} />
<InspectorSelectField
label="Режим"
value={activeGridLod.mode}
options={GRID_MODE_OPTIONS}
onChange={(mode) => updateGridLod({
mode,
volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled,
...(mode === "3d" ? {
stepKm: Math.min(50, activeGridLod.stepKm),
tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm),
} : {
graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees),
}),
})}
/>
<RangeControl label="Высота WGS84" value={activeGridLod.heightMeters} min={0} max={5_000} step={10} formatValue={(value) => `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} />
<RangeControl label="Конус видимости 3D" value={activeGridLod.max3dViewAngleDegrees} min={30} max={170} step={1} formatValue={(value) => `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} />
<RangeControl label="Шаг ENU-секторов" value={activeGridLod.stepKm} min={0.1} max={5_000} step={0.1} formatValue={(value) => `${value} км`} onChange={(stepKm) => updateGridLod({ stepKm, radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX) })} />
<RangeControl label="Размер тайла ENU" value={activeGridLod.tileSizeKm} min={1} max={50} step={1} formatValue={(value) => `${value} км`} onChange={(tileSizeKm) => updateGridLod({ tileSizeKm })} />
<RangeControl
label="Шаг ENU-секторов"
value={activeGridLod.stepKm}
min={0.1}
max={activeGridLod.mode === "3d" ? 50 : 5_000}
step={0.1}
formatValue={(value) => `${value} км`}
onChange={(stepKm) => updateGridLod({
stepKm,
tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)),
radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX),
})}
/>
<RangeControl
label="Размер major-тайла ENU"
value={activeGridLod.tileSizeKm}
min={activeGridLod.stepKm}
max={Math.max(activeGridLod.stepKm, 50)}
step={activeGridLod.stepKm}
formatValue={(value) => `${value} км`}
onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })}
/>
<small className="catalog-map-inspector__note">Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам.</small>
<Checker
checked={activeGridLod.majorLinesEnabled}
label="Major-линии"
onChange={(majorLinesEnabled) => updateGridLod({
majorLinesEnabled,
majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled,
})}
/>
<Checker checked={activeGridLod.majorLabelsEnabled} disabled={!activeGridLod.majorLinesEnabled} label="Подписи major-тайлов" onChange={(majorLabelsEnabled) => updateGridLod({ majorLabelsEnabled })} />
<RangeControl label="Толщина major-линий" value={activeGridLod.majorLineWidthMultiplier} min={1} max={8} step={0.1} formatValue={(value) => `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} />
<small className="catalog-map-inspector__note">Прозрачность major-линий наследует прозрачность линий текущего LOD.</small>
{activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null
? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант.</small>
: null}
<RangeControl label="Радиус ENU-поля" value={activeGridLod.radiusKm} min={1} max={Math.min(100_000, activeGridLod.stepKm * MAX_LOCAL_GRID_INDEX)} step={1} formatValue={(value) => `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} />
<RangeControl label="Диаметр 3D-линий" value={activeGridLod.lineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} />
<ControlRow label="Цвет 3D-линий"><ColorField label="Цвет линий ENU-сетки" value={activeGridLod.lineColor} onChange={(lineColor) => updateGridLod({ lineColor })} /></ControlRow>
@@ -1341,14 +1715,132 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label="Кресты: ширина" value={activeGridLod.crossesWidthMeters} min={1} max={500} step={1} formatValue={(value) => `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} />
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={activeGridLod.crossesColor} onChange={(crossesColor) => updateGridLod({ crossesColor })} /></ControlRow>
<RangeControl label="Кресты: прозрачность" value={activeGridLod.crossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} />
<RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees })} />
<RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} />
<RangeControl label="Толщина гратикулы" value={activeGridLod.graticuleLineWidthPx} min={1} max={3} step={1} formatValue={(value) => `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} />
<ControlRow label="Цвет гратикулы"><ColorField label="Цвет WGS84-гратику́лы" value={activeGridLod.graticuleColor} onChange={(graticuleColor) => updateGridLod({ graticuleColor })} /></ControlRow>
<RangeControl label="Прозрачность гратикулы" value={activeGridLod.graticuleOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} />
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
{selectedGridSector ? <small className="catalog-map-inspector__note">LOD {selectedGridSector.lod} · {selectedGridSector.units === "meters-enu"
? `ENU ${selectedGridSector.bounds.west}${selectedGridSector.bounds.east} м E; ${selectedGridSector.bounds.south}${selectedGridSector.bounds.north} м N`
: `WGS84 ${selectedGridSector.bounds.west}${selectedGridSector.bounds.east}°; ${selectedGridSector.bounds.south}${selectedGridSector.bounds.north}°`}</small> : null}
{activeGridLod.mode === "3d" ? <>
<Checker checked={activeGridLod.volumeEnabled} label="Объёмный выбор сектора" onChange={(volumeEnabled) => updateGridLod({ volumeEnabled })} />
<RangeControl
label="Нижняя отметка объёма"
value={activeGridLod.volumeMinimumHeightMeters}
min={-1_000}
max={activeGridLod.volumeMaximumHeightMeters - 1}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })}
/>
<RangeControl
label="Верхняя отметка объёма"
value={activeGridLod.volumeMaximumHeightMeters}
min={activeGridLod.volumeMinimumHeightMeters + 1}
max={10_000}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })}
/>
<RangeControl
label="Высота адресного диапазона"
value={activeGridLod.volumeBandHeightMeters}
min={1}
max={Math.max(1, activeGridLod.volumeMaximumHeightMeters - activeGridLod.volumeMinimumHeightMeters)}
step={10}
formatValue={(value) => `${value} м`}
onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })}
/>
<small className="catalog-map-inspector__note">Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки.</small>
</> : null}
<section className="catalog-map-grid-sector" aria-label="Выбранный сектор">
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
{selectedGridSector ? <>
<Button
variant="secondary"
size="compact"
width="full"
shape="pill"
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
onClick={() => void copySelectedGridSectorId()}
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
{gridSectorCopyState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Не удалось записать ID в буфер обмена.</small> : null}
<div className="catalog-map-grid-sector__facts">
<ControlRow label="Family / LOD"><strong>{selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod}</strong></ControlRow>
<ControlRow label="Адрес"><span>{selectedGridSector.label}</span></ControlRow>
<ControlRow label="Границы"><span>{gridSectorBoundsLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Центр"><span>{gridSectorCenterLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Площадь"><strong>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</strong></ControlRow>
</div>
{selectedGridSector.parentMajorTile ? <div className="catalog-map-grid-sector__relation">
<small>Parent major tile · {selectedGridSector.parentMajorTile.label}</small>
<code title={selectedGridSector.parentMajorTile.id}>{selectedGridSector.parentMajorTile.id}</code>
<small>{selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)}</small>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="parent-major"
disabled={!mapRendererReady}
onClick={() => focusGridMajorTile(selectedGridSector.parentMajorTile!)}
>Фокус major-тайла</Button>
</div> : <small className="catalog-map-inspector__note">Parent major tile выключен или недоступен для текущей топологии.</small>}
<div className="catalog-map-grid-sector__relation">
<small>Следующий LOD</small>
{selectedGridParentLod ? <>
<code title={selectedGridParentLod.id}>{selectedGridParentLod.id}</code>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="next-lod"
disabled={!mapRendererReady}
onClick={() => focusGridSector(selectedGridParentLod)}
>Перейти в LOD {selectedGridParentLod.lod}</Button>
</> : <span>{selectedGridSector.lod >= sectorGridLodProfiles.length
? "Верхний уровень иерархии"
: `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}</span>}
</div>
<div className="catalog-map-grid-sector__neighbors" aria-label="Соседние сектора">
{GRID_SECTOR_DIRECTIONS.map(({ id, label }) => {
const target = selectedGridNeighborTargets[id];
return <div className="catalog-map-grid-sector__neighbor" key={id}>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent={id}
disabled={!target || !mapRendererReady}
onClick={() => focusGridSector(target)}
>{label}</Button>
<code title={target?.id}>{target?.id ?? "Граница адресного пространства"}</code>
</div>;
})}
</div>
{selectedGridSector.mode === "3d" && selectedGridSectorProfile ? <div className="catalog-map-grid-sector__volume" data-enabled={selectedGridSectorProfile.volumeEnabled || undefined}>
<ControlRow label="Высотный выбор"><strong>{selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"}</strong></ControlRow>
<ControlRow label="Floor"><span>{selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Ceiling"><span>{selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Height band"><span>{selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м</span></ControlRow>
{selectedGridSector.volume ? <code title={selectedGridSector.volume.id}>{selectedGridSector.volume.id}</code> : null}
{selectedGridSectorProfile.volumeEnabled ? <div className="catalog-map-grid-sector__volume-actions">
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="below"
disabled={!selectedGridVolumeTargets.below || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.below)}
>Ниже</Button>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="above"
disabled={!selectedGridVolumeTargets.above || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.above)}
>Выше</Button>
</div> : null}
</div> : null}
</> : <small className="catalog-map-inspector__note">Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.</small>}
</section>
</>,
},
{
@@ -1479,6 +1971,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
ref={mapRendererRef}
onSelect={handleSelect}
onGridSectorSelect={setSelectedGridSector}
selectedGridSector={selectedGridSector}
onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus}
onCameraChange={handleCameraChange}
+7
View File
@@ -25,6 +25,13 @@ export type GridLodProfile = {
graticuleLineWidthPx: number;
graticuleColor: string;
graticuleOpacity: number;
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
};
export type GridLodBand = GridLodProfile & {
index: number;
+17
View File
@@ -16,6 +16,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 7, dotsColor: "#ffffff", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300,
}),
Object.freeze({
maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -23,6 +25,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 80, dotsColor: "#ffffff", dotsOpacity: 22,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -30,6 +34,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#ffffff", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -37,6 +43,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#fcfdfd", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
// Engine keeps the last LOD selected above this threshold; the independent
@@ -46,6 +54,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#9c9c9c", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
]);
@@ -81,6 +91,13 @@ export function gridLodProfile(settings, index) {
graticuleLineWidthPx: clamp(finite(source.graticuleLineWidthPx ?? source.lineWidthPx, finite(settings.gridLineWidth, fallback.graticuleLineWidthPx)), 1, 3),
graticuleColor: color(source.graticuleColor, color(source.lineColor, color(settings.gridColor, fallback.graticuleColor))),
graticuleOpacity: clamp(finite(source.graticuleOpacity, finite(source.lineOpacity, finite(settings.gridOpacity, fallback.graticuleOpacity))), 0, 100),
majorLinesEnabled: source.majorLinesEnabled ?? fallback.majorLinesEnabled,
majorLabelsEnabled: source.majorLabelsEnabled ?? fallback.majorLabelsEnabled,
majorLineWidthMultiplier: clamp(finite(source.majorLineWidthMultiplier, fallback.majorLineWidthMultiplier), 1, 8),
volumeEnabled: source.volumeEnabled ?? fallback.volumeEnabled,
volumeMinimumHeightMeters: clamp(finite(source.volumeMinimumHeightMeters, fallback.volumeMinimumHeightMeters), -1_000, 10_000),
volumeMaximumHeightMeters: clamp(finite(source.volumeMaximumHeightMeters, fallback.volumeMaximumHeightMeters), -1_000, 10_000),
volumeBandHeightMeters: clamp(finite(source.volumeBandHeightMeters, fallback.volumeBandHeightMeters), 1, 10_000),
};
}
+208 -2
View File
@@ -1,3 +1,8 @@
export type LocalBounds = { west: number; east: number; south: number; north: number };
export type LocalCenter = { eastMeters: number; northMeters: number };
export type GeodeticBounds = { west: number; east: number; south: number; north: number };
export type GeodeticCenter = { longitude: number; latitude: number };
export type LocalGridDefinition = {
lod: number;
originLatitude: number;
@@ -5,6 +10,15 @@ export type LocalGridDefinition = {
stepMeters: number;
};
export type LocalHierarchyDefinition = LocalGridDefinition & { tileSizeMeters: number };
export type LocalVolumeDefinition = LocalGridDefinition & {
altitudeFloorMeters?: number;
altitudeCeilingMeters?: number;
/** @deprecated Use altitudeFloorMeters. */
altitudeOriginMeters?: number;
altitudeBandMeters: number;
};
export type LocalSectorAddress = {
family: "local-enu";
lod: number;
@@ -13,7 +27,29 @@ export type LocalSectorAddress = {
id: string;
};
export type LocalMajorTileAddress = {
family: "local-enu-major";
lod: number;
eastIndex: number;
northIndex: number;
minorPerSide: number;
id: string;
};
export type LocalVolumeAddress = {
family: "local-enu-volume";
lod: number;
eastIndex: number;
northIndex: number;
bandIndex: number;
altitudeFloorMeters: number;
altitudeCeilingMeters: number;
altitudeBandMeters: number;
id: string;
};
export type GraticuleDefinition = { lod: number; stepDegrees: number };
export type GraticuleHierarchyDefinition = GraticuleDefinition & { majorStepDegrees: number };
export type GraticuleSectorAddress = {
family: "wgs84-graticule";
lod: number;
@@ -22,15 +58,158 @@ export type GraticuleSectorAddress = {
id: string;
};
export type GraticuleMajorTileAddress = {
family: "wgs84-graticule-major";
lod: number;
longitudeIndex: number;
latitudeIndex: number;
minorPerSide: number;
id: string;
};
export type AddressDetail<Address, Bounds, Center> = {
address: Address;
bounds: Bounds;
center: Center;
areaSquareMeters: number;
};
export type LocalSectorDetail = AddressDetail<LocalSectorAddress, LocalBounds, LocalCenter>;
export type LocalMajorTileDetail = AddressDetail<LocalMajorTileAddress, LocalBounds, LocalCenter>;
export type GraticuleSectorDetail = AddressDetail<GraticuleSectorAddress, GeodeticBounds, GeodeticCenter>;
export type GraticuleMajorTileDetail = AddressDetail<GraticuleMajorTileAddress, GeodeticBounds, GeodeticCenter>;
export type LocalSectorSummary = {
id: string;
address: LocalSectorAddress;
family: "local-enu";
lod: number;
label: string;
indices: { eastIndex: number; northIndex: number };
bounds: LocalBounds;
center: LocalCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", LocalSectorDetail>;
majorTile: LocalMajorTileSummary | null;
parentMajorTile: LocalMajorTileSummary | null;
};
export type LocalMajorTileSummary = {
id: string;
address: LocalMajorTileAddress;
family: "local-enu-major";
lod: number;
label: string;
indices: { eastIndex: number; northIndex: number };
minorPerSide: number;
childCount: number;
bounds: LocalBounds;
center: LocalCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", LocalMajorTileDetail>;
};
export type LocalVolumeBounds = LocalBounds & {
altitudeFloorMeters: number;
altitudeCeilingMeters: number;
};
export type LocalVolumeSummary = {
id: string;
address: LocalVolumeAddress;
family: "local-enu-volume";
lod: number;
label: string;
indices: { eastIndex: number; northIndex: number; bandIndex: number };
bounds: LocalVolumeBounds;
center: LocalCenter & { altitudeMeters: number };
footprintAreaSquareMeters: number;
volumeCubicMeters: number;
};
export type GraticuleSectorSummary = {
id: string;
address: GraticuleSectorAddress;
family: "wgs84-graticule";
lod: number;
label: string;
indices: { longitudeIndex: number; latitudeIndex: number };
bounds: GeodeticBounds;
center: GeodeticCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", GraticuleSectorDetail | null>;
majorTile: GraticuleMajorTileSummary | null;
parentMajorTile: GraticuleMajorTileSummary | null;
};
export type GraticuleMajorTileSummary = {
id: string;
address: GraticuleMajorTileAddress;
family: "wgs84-graticule-major";
lod: number;
label: string;
indices: { longitudeIndex: number; latitudeIndex: number };
minorPerSide: number;
childCount: number;
bounds: GeodeticBounds;
center: GeodeticCenter;
areaSquareMeters: number;
neighbors: Record<"north" | "east" | "south" | "west", GraticuleMajorTileDetail | null>;
};
export const MAX_LOCAL_GRID_INDEX: number;
export const MAX_GRID_CHILD_PAGE_SIZE: number;
export type GridChildPage = { offset?: number; limit?: number };
export function normalizeLongitudeDegrees(value: number): number;
export function fixedGridOrigin(settings: { gridCenterLatitude?: number; gridCenterLongitude?: number }): { latitude: number; longitude: number };
// Existing horizontal sector IDs are intentionally unchanged in v2.
export function localSectorId(definition: LocalGridDefinition, eastIndex: number, northIndex: number): string;
export function localSectorAt(point: { eastMeters: number; northMeters: number }, definition: LocalGridDefinition): LocalSectorAddress;
export function localSectorBounds(address: LocalSectorAddress, stepMeters: number): { west: number; east: number; south: number; north: number };
export function localSectorBounds(address: LocalSectorAddress, stepMeters: number): LocalBounds;
export function localSectorNeighbors(address: LocalSectorAddress, definition: LocalGridDefinition): Record<"north" | "east" | "south" | "west", LocalSectorAddress>;
export function localParentSector(address: LocalSectorAddress, childStepMeters: number, parentDefinition: LocalGridDefinition): LocalSectorAddress;
export function localSectorCenter(address: LocalSectorAddress, stepMeters: number): LocalCenter;
export function localSectorAreaSquareMeters(address: LocalSectorAddress, stepMeters: number): number;
export function localSectorSummary(
address: LocalSectorAddress,
definition: LocalGridDefinition & { tileSizeMeters?: number },
): LocalSectorSummary;
export function localMajorTileId(definition: LocalHierarchyDefinition, eastIndex: number, northIndex: number): string;
export function localMajorTileAt(point: { eastMeters: number; northMeters: number }, definition: LocalHierarchyDefinition): LocalMajorTileAddress;
export function localMajorTileBounds(address: LocalMajorTileAddress, tileSizeMeters: number): LocalBounds;
export function localMajorTileForSector(address: LocalSectorAddress, definition: LocalHierarchyDefinition): LocalMajorTileAddress;
export function localMajorTileChildren(
address: LocalMajorTileAddress,
definition: LocalHierarchyDefinition,
options?: GridChildPage,
): LocalSectorAddress[];
export function localMajorTileNeighbors(address: LocalMajorTileAddress, definition: LocalHierarchyDefinition): Record<"north" | "east" | "south" | "west", LocalMajorTileAddress>;
export function isLocalMajorLineIndex(lineIndex: number, definition: LocalHierarchyDefinition): boolean;
export function localMajorTileCenter(address: LocalMajorTileAddress, tileSizeMeters: number): LocalCenter;
export function localMajorTileAreaSquareMeters(address: LocalMajorTileAddress, tileSizeMeters: number): number;
export function localMajorTileSummary(address: LocalMajorTileAddress, definition: LocalHierarchyDefinition): LocalMajorTileSummary;
export function localVolumeId(definition: LocalVolumeDefinition, eastIndex: number, northIndex: number, bandIndex: number): string;
export function localVolumeAt(
point: { eastMeters: number; northMeters: number; altitudeMeters: number },
definition: LocalVolumeDefinition,
): LocalVolumeAddress | null;
export function localVolumeBounds(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalVolumeBounds;
export function localVolumeNeighbors(address: LocalVolumeAddress, definition: LocalVolumeDefinition): {
north: LocalVolumeAddress;
east: LocalVolumeAddress;
south: LocalVolumeAddress;
west: LocalVolumeAddress;
above: LocalVolumeAddress | null;
below: LocalVolumeAddress | null;
};
export function localVolumeCenter(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalCenter & { altitudeMeters: number };
export function localVolumeSummary(address: LocalVolumeAddress, definition: LocalVolumeDefinition): LocalVolumeSummary;
export function localGridPlan(input: { stepMeters: number; radiusMeters: number; maximumMarkers?: number }): {
stepMeters: number;
radiusMeters: number;
@@ -40,9 +219,36 @@ export function localGridPlan(input: { stepMeters: number; radiusMeters: number;
markerStride: number;
lines: Array<{ index: number; offsetMeters: number; extentMeters: number }>;
};
// Existing horizontal graticule IDs are intentionally unchanged in v2.
export function graticuleSectorId(definition: GraticuleDefinition, longitudeIndex: number, latitudeIndex: number): string;
export function graticuleSectorAt(point: { longitude: number; latitude: number }, definition: GraticuleDefinition): GraticuleSectorAddress;
export function graticuleSectorBounds(address: GraticuleSectorAddress, stepDegrees: number): { west: number; east: number; south: number; north: number };
export function graticuleSectorBounds(address: GraticuleSectorAddress, stepDegrees: number): GeodeticBounds;
export function graticuleSectorNeighbors(address: GraticuleSectorAddress, definition: GraticuleDefinition): Record<"north" | "east" | "south" | "west", GraticuleSectorAddress | null>;
export function graticuleSectorCenter(address: GraticuleSectorAddress, stepDegrees: number): GeodeticCenter;
export function graticuleSectorAreaSquareMeters(address: GraticuleSectorAddress, stepDegrees: number): number;
export function graticuleSectorSummary(
address: GraticuleSectorAddress,
definition: GraticuleDefinition & { majorStepDegrees?: number },
): GraticuleSectorSummary;
export function graticuleMajorTileId(definition: GraticuleHierarchyDefinition, longitudeIndex: number, latitudeIndex: number): string;
export function graticuleMajorTileAt(point: { longitude: number; latitude: number }, definition: GraticuleHierarchyDefinition): GraticuleMajorTileAddress;
export function graticuleMajorTileBounds(address: GraticuleMajorTileAddress, majorStepDegrees: number): GeodeticBounds;
export function graticuleMajorTileForSector(address: GraticuleSectorAddress, definition: GraticuleHierarchyDefinition): GraticuleMajorTileAddress;
export function graticuleMajorTileChildren(
address: GraticuleMajorTileAddress,
definition: GraticuleHierarchyDefinition,
options?: GridChildPage,
): GraticuleSectorAddress[];
export function graticuleMajorTileNeighbors(address: GraticuleMajorTileAddress, definition: GraticuleHierarchyDefinition): Record<"north" | "east" | "south" | "west", GraticuleMajorTileAddress | null>;
export function isGraticuleMajorLineIndex(lineIndex: number, definition: GraticuleHierarchyDefinition): boolean;
export function isGraticuleMajorLineValue(valueDegrees: number, definition: GraticuleHierarchyDefinition): boolean;
export function graticuleMajorTileCenter(address: GraticuleMajorTileAddress, majorStepDegrees: number): GeodeticCenter;
export function graticuleMajorTileAreaSquareMeters(address: GraticuleMajorTileAddress, majorStepDegrees: number): number;
export function graticuleMajorTileSummary(address: GraticuleMajorTileAddress, definition: GraticuleHierarchyDefinition): GraticuleMajorTileSummary;
export function geodeticRectangleAreaSquareMeters(bounds: GeodeticBounds): number;
export function splitLongitudeRange(west: number, east: number): Array<{ west: number; east: number }>;
export function alignedGridValues(minimum: number, maximum: number, step: number, options?: { includeMaximum?: boolean }): number[];
export function boundedAngularParts(start: number, end: number, maximumSpanDegrees?: number): Array<{ start: number; end: number }>;
+730
View File
@@ -1,6 +1,10 @@
const EPSILON = 1e-9;
const WGS84_EQUATORIAL_RADIUS_METERS = 6_378_137;
const WGS84_FLATTENING = 1 / 298.257223563;
const WGS84_ECCENTRICITY_SQUARED = WGS84_FLATTENING * (2 - WGS84_FLATTENING);
const WGS84_ECCENTRICITY = Math.sqrt(WGS84_ECCENTRICITY_SQUARED);
export const MAX_LOCAL_GRID_INDEX = 512;
export const MAX_GRID_CHILD_PAGE_SIZE = 10_000;
const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
@@ -8,6 +12,42 @@ const canonicalZero = (value) => Object.is(value, -0) ? 0 : value;
const signedIndex = (value) => value >= 0 ? `+${value}` : String(value);
const decimalToken = (value, digits) => Number(value).toFixed(digits).replace("-0.", "0.");
const positiveNumber = (value, errorCode) => {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) throw new Error(errorCode);
return number;
};
const safeInteger = (value, errorCode = "grid_index_must_be_safe_integer") => {
const number = Number(value);
if (!Number.isSafeInteger(number)) throw new Error(errorCode);
return canonicalZero(number);
};
const integerMultiple = (outer, inner, errorCode) => {
const ratio = outer / inner;
const rounded = Math.round(ratio);
if (!Number.isSafeInteger(rounded)
|| rounded < 1
|| Math.abs(ratio - rounded) > EPSILON * Math.max(1, Math.abs(ratio))) {
throw new Error(errorCode);
}
return rounded;
};
function childPageRange(totalCount, options) {
const total = safeInteger(totalCount, "grid_child_count_must_be_safe_integer");
const offset = safeInteger(options?.offset ?? 0, "grid_child_offset_must_be_safe_integer");
if (offset < 0) throw new Error("grid_child_offset_must_be_non_negative");
const remaining = Math.max(0, total - offset);
const limit = options?.limit == null
? remaining
: safeInteger(options.limit, "grid_child_limit_must_be_safe_integer");
if (limit < 1 && remaining > 0) throw new Error("grid_child_limit_must_be_positive");
if (limit > MAX_GRID_CHILD_PAGE_SIZE) throw new Error("grid_child_page_limit_exceeded");
return { start: Math.min(offset, total), end: Math.min(total, offset + Math.max(0, limit)) };
}
export function normalizeLongitudeDegrees(value) {
const longitude = finite(value, 0);
return canonicalZero(((longitude + 180) % 360 + 360) % 360 - 180);
@@ -81,6 +121,360 @@ export function localParentSector(address, childStepMeters, parentDefinition) {
}, parentDefinition);
}
function localHierarchyMetrics(definition) {
const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive");
const tileSizeMeters = positiveNumber(definition?.tileSizeMeters, "grid_tile_size_must_be_positive");
const minorPerMajor = integerMultiple(
tileSizeMeters,
stepMeters,
"grid_tile_size_must_be_integer_multiple_of_step",
);
return { stepMeters, tileSizeMeters, minorPerMajor };
}
function localMajorDefinitionToken(definition, metrics = localHierarchyMetrics(definition)) {
return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/t${decimalToken(metrics.tileSizeMeters, 3)}`;
}
export function localMajorTileId(definition, eastIndex, northIndex) {
const metrics = localHierarchyMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
return `grid/local/${localMajorDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}`;
}
function localMajorAddress(definition, eastIndex, northIndex) {
const metrics = localHierarchyMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
return {
family: "local-enu-major",
lod: definition.lod,
eastIndex: east,
northIndex: north,
minorPerSide: metrics.minorPerMajor,
id: localMajorTileId(definition, east, north),
};
}
export function localMajorTileAt(point, definition) {
const { tileSizeMeters } = localHierarchyMetrics(definition);
return localMajorAddress(
definition,
Math.floor(finite(point?.eastMeters, 0) / tileSizeMeters),
Math.floor(finite(point?.northMeters, 0) / tileSizeMeters),
);
}
export function localMajorTileBounds(address, tileSizeMeters) {
const size = positiveNumber(tileSizeMeters, "grid_tile_size_must_be_positive");
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
return {
west: eastIndex * size,
east: (eastIndex + 1) * size,
south: northIndex * size,
north: (northIndex + 1) * size,
};
}
export function localMajorTileForSector(address, definition) {
const { minorPerMajor } = localHierarchyMetrics(definition);
return localMajorAddress(
definition,
Math.floor(safeInteger(address?.eastIndex) / minorPerMajor),
Math.floor(safeInteger(address?.northIndex) / minorPerMajor),
);
}
export function localMajorTileChildren(address, definition, options) {
const { minorPerMajor } = localHierarchyMetrics(definition);
const majorEastIndex = safeInteger(address?.eastIndex);
const majorNorthIndex = safeInteger(address?.northIndex);
const firstEastIndex = safeInteger(majorEastIndex * minorPerMajor);
const firstNorthIndex = safeInteger(majorNorthIndex * minorPerMajor);
safeInteger(firstEastIndex + minorPerMajor - 1);
safeInteger(firstNorthIndex + minorPerMajor - 1);
const page = childPageRange(minorPerMajor ** 2, options);
const children = [];
for (let childIndex = page.start; childIndex < page.end; childIndex += 1) {
const eastIndex = firstEastIndex + childIndex % minorPerMajor;
const northIndex = firstNorthIndex + Math.floor(childIndex / minorPerMajor);
children.push({
family: "local-enu",
lod: definition.lod,
eastIndex,
northIndex,
id: localSectorId(definition, eastIndex, northIndex),
});
}
return children;
}
export function localMajorTileNeighbors(address, definition) {
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
return {
north: localMajorAddress(definition, eastIndex, northIndex + 1),
east: localMajorAddress(definition, eastIndex + 1, northIndex),
south: localMajorAddress(definition, eastIndex, northIndex - 1),
west: localMajorAddress(definition, eastIndex - 1, northIndex),
};
}
export function isLocalMajorLineIndex(lineIndex, definition) {
const { minorPerMajor } = localHierarchyMetrics(definition);
return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0;
}
export function localSectorCenter(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return {
eastMeters: (bounds.west + bounds.east) / 2,
northMeters: (bounds.south + bounds.north) / 2,
};
}
export function localSectorAreaSquareMeters(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return (bounds.east - bounds.west) * (bounds.north - bounds.south);
}
function localSectorDetail(address, stepMeters) {
const bounds = localSectorBounds(address, stepMeters);
return {
address,
bounds,
center: localSectorCenter(address, stepMeters),
areaSquareMeters: localSectorAreaSquareMeters(address, stepMeters),
};
}
export function localSectorSummary(address, definition) {
const bounds = localSectorBounds(address, definition.stepMeters);
const neighbors = Object.fromEntries(
Object.entries(localSectorNeighbors(address, definition))
.map(([direction, neighbor]) => [direction, localSectorDetail(neighbor, definition.stepMeters)]),
);
const majorTile = definition?.tileSizeMeters == null
? null
: localMajorTileSummary(localMajorTileForSector(address, definition), definition);
return {
id: address.id,
address,
family: "local-enu",
lod: address.lod,
label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`,
indices: { eastIndex: address.eastIndex, northIndex: address.northIndex },
bounds,
center: localSectorCenter(address, definition.stepMeters),
areaSquareMeters: localSectorAreaSquareMeters(address, definition.stepMeters),
neighbors,
majorTile,
parentMajorTile: majorTile,
};
}
export function localMajorTileCenter(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return {
eastMeters: (bounds.west + bounds.east) / 2,
northMeters: (bounds.south + bounds.north) / 2,
};
}
export function localMajorTileAreaSquareMeters(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return (bounds.east - bounds.west) * (bounds.north - bounds.south);
}
function localMajorTileDetail(address, tileSizeMeters) {
const bounds = localMajorTileBounds(address, tileSizeMeters);
return {
address,
bounds,
center: localMajorTileCenter(address, tileSizeMeters),
areaSquareMeters: localMajorTileAreaSquareMeters(address, tileSizeMeters),
};
}
export function localMajorTileSummary(address, definition) {
const metrics = localHierarchyMetrics(definition);
const bounds = localMajorTileBounds(address, metrics.tileSizeMeters);
const neighbors = Object.fromEntries(
Object.entries(localMajorTileNeighbors(address, definition))
.map(([direction, neighbor]) => [direction, localMajorTileDetail(neighbor, metrics.tileSizeMeters)]),
);
return {
id: address.id,
address,
family: "local-enu-major",
lod: address.lod,
label: `L${address.lod} TILE E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)}`,
indices: { eastIndex: address.eastIndex, northIndex: address.northIndex },
minorPerSide: metrics.minorPerMajor,
childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"),
bounds,
center: localMajorTileCenter(address, metrics.tileSizeMeters),
areaSquareMeters: localMajorTileAreaSquareMeters(address, metrics.tileSizeMeters),
neighbors,
};
}
function localVolumeMetrics(definition) {
const stepMeters = positiveNumber(definition?.stepMeters, "grid_step_must_be_positive");
const altitudeBandMeters = positiveNumber(
definition?.altitudeBandMeters,
"grid_altitude_band_must_be_positive",
);
const altitudeFloorMeters = Number(
definition?.altitudeFloorMeters ?? definition?.altitudeOriginMeters ?? 0,
);
if (!Number.isFinite(altitudeFloorMeters)) {
throw new Error("grid_altitude_floor_must_be_finite");
}
const altitudeCeilingMeters = definition?.altitudeCeilingMeters == null
? Number.POSITIVE_INFINITY
: Number(definition.altitudeCeilingMeters);
if ((!Number.isFinite(altitudeCeilingMeters) && altitudeCeilingMeters !== Number.POSITIVE_INFINITY)
|| altitudeCeilingMeters <= altitudeFloorMeters) {
throw new Error("grid_altitude_ceiling_must_exceed_floor");
}
return { stepMeters, altitudeBandMeters, altitudeFloorMeters, altitudeCeilingMeters };
}
function localVolumeDefinitionToken(definition, metrics = localVolumeMetrics(definition)) {
const ceilingToken = Number.isFinite(metrics.altitudeCeilingMeters)
? decimalToken(metrics.altitudeCeilingMeters, 3)
: "inf";
return `${localDefinitionToken({ ...definition, stepMeters: metrics.stepMeters })}/f${decimalToken(metrics.altitudeFloorMeters, 3)}/c${ceilingToken}/h${decimalToken(metrics.altitudeBandMeters, 3)}`;
}
export function localVolumeId(definition, eastIndex, northIndex, bandIndex) {
const metrics = localVolumeMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (band < 0
|| metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
return `grid/local-volume/${localVolumeDefinitionToken(definition, metrics)}/e${signedIndex(east)}/n${signedIndex(north)}/z${signedIndex(band)}`;
}
function localVolumeAddress(definition, eastIndex, northIndex, bandIndex) {
const metrics = localVolumeMetrics(definition);
const east = safeInteger(eastIndex);
const north = safeInteger(northIndex);
const band = safeInteger(bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (band < 0) throw new Error("grid_altitude_band_index_out_of_range");
const altitudeFloorMeters = metrics.altitudeFloorMeters + band * metrics.altitudeBandMeters;
if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
const altitudeCeilingMeters = Math.min(
metrics.altitudeCeilingMeters,
altitudeFloorMeters + metrics.altitudeBandMeters,
);
return {
family: "local-enu-volume",
lod: definition.lod,
eastIndex: east,
northIndex: north,
bandIndex: band,
altitudeFloorMeters,
altitudeCeilingMeters,
altitudeBandMeters: altitudeCeilingMeters - altitudeFloorMeters,
id: localVolumeId(definition, east, north, band),
};
}
export function localVolumeAt(point, definition) {
const metrics = localVolumeMetrics(definition);
const altitudeMeters = Number(point?.altitudeMeters);
if (!Number.isFinite(altitudeMeters)
|| altitudeMeters < metrics.altitudeFloorMeters
|| altitudeMeters >= metrics.altitudeCeilingMeters) {
return null;
}
const eastIndex = Math.floor(finite(point?.eastMeters, 0) / metrics.stepMeters);
const northIndex = Math.floor(finite(point?.northMeters, 0) / metrics.stepMeters);
const bandIndex = Math.floor(
(altitudeMeters - metrics.altitudeFloorMeters) / metrics.altitudeBandMeters,
);
return localVolumeAddress(definition, eastIndex, northIndex, bandIndex);
}
export function localVolumeBounds(address, definition) {
const metrics = localVolumeMetrics(definition);
const horizontal = localSectorBounds(address, metrics.stepMeters);
const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer");
if (bandIndex < 0) throw new Error("grid_altitude_band_index_out_of_range");
const altitudeFloorMeters = metrics.altitudeFloorMeters + bandIndex * metrics.altitudeBandMeters;
if (altitudeFloorMeters >= metrics.altitudeCeilingMeters) {
throw new Error("grid_altitude_band_index_out_of_range");
}
return {
...horizontal,
altitudeFloorMeters,
altitudeCeilingMeters: Math.min(
metrics.altitudeCeilingMeters,
altitudeFloorMeters + metrics.altitudeBandMeters,
),
};
}
export function localVolumeNeighbors(address, definition) {
const metrics = localVolumeMetrics(definition);
const eastIndex = safeInteger(address?.eastIndex);
const northIndex = safeInteger(address?.northIndex);
const bandIndex = safeInteger(address?.bandIndex, "grid_altitude_band_index_must_be_safe_integer");
const aboveFloor = metrics.altitudeFloorMeters + (bandIndex + 1) * metrics.altitudeBandMeters;
return {
north: localVolumeAddress(definition, eastIndex, northIndex + 1, bandIndex),
east: localVolumeAddress(definition, eastIndex + 1, northIndex, bandIndex),
south: localVolumeAddress(definition, eastIndex, northIndex - 1, bandIndex),
west: localVolumeAddress(definition, eastIndex - 1, northIndex, bandIndex),
above: aboveFloor >= metrics.altitudeCeilingMeters
? null
: localVolumeAddress(definition, eastIndex, northIndex, bandIndex + 1),
below: bandIndex === 0
? null
: localVolumeAddress(definition, eastIndex, northIndex, bandIndex - 1),
};
}
export function localVolumeCenter(address, definition) {
const horizontal = localSectorCenter(address, definition.stepMeters);
const bounds = localVolumeBounds(address, definition);
return {
...horizontal,
altitudeMeters: (bounds.altitudeFloorMeters + bounds.altitudeCeilingMeters) / 2,
};
}
export function localVolumeSummary(address, definition) {
const bounds = localVolumeBounds(address, definition);
const footprintAreaSquareMeters = (bounds.east - bounds.west) * (bounds.north - bounds.south);
return {
id: address.id,
address,
family: "local-enu-volume",
lod: address.lod,
label: `L${address.lod} E${signedIndex(address.eastIndex)} N${signedIndex(address.northIndex)} Z${signedIndex(address.bandIndex)}`,
indices: {
eastIndex: address.eastIndex,
northIndex: address.northIndex,
bandIndex: address.bandIndex,
},
bounds,
center: localVolumeCenter(address, definition),
footprintAreaSquareMeters,
volumeCubicMeters: footprintAreaSquareMeters
* (bounds.altitudeCeilingMeters - bounds.altitudeFloorMeters),
};
}
export function localGridPlan({ stepMeters, radiusMeters, maximumMarkers = 5_000 }) {
const step = Math.max(1, finite(stepMeters, 1));
const requestedRadius = Math.max(step, finite(radiusMeters, step));
@@ -138,6 +532,342 @@ export function graticuleSectorBounds(address, stepDegrees) {
};
}
function graticuleTopology(stepDegrees, errorCode = "grid_graticule_step_must_partition_hemisphere") {
const step = positiveNumber(stepDegrees, "grid_graticule_step_must_be_positive");
const longitudeHemisphereCount = integerMultiple(180, step, errorCode);
const latitudeHemisphereCount = integerMultiple(90, step, errorCode);
return {
stepDegrees: step,
longitudeHemisphereCount,
latitudeHemisphereCount,
longitudeCount: longitudeHemisphereCount * 2,
latitudeCount: latitudeHemisphereCount * 2,
};
}
function wrapGraticuleIndex(index, hemisphereCount) {
const span = hemisphereCount * 2;
return canonicalZero(((index + hemisphereCount) % span + span) % span - hemisphereCount);
}
function graticuleSectorAddress(definition, longitudeIndex, latitudeIndex) {
const longitude = safeInteger(longitudeIndex);
const latitude = safeInteger(latitudeIndex);
return {
family: "wgs84-graticule",
lod: definition.lod,
longitudeIndex: longitude,
latitudeIndex: latitude,
id: graticuleSectorId(definition, longitude, latitude),
};
}
export function graticuleSectorNeighbors(address, definition) {
const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive");
const minimumLongitudeIndex = Math.floor(-180 / stepDegrees);
const maximumLongitudeIndex = Math.floor((180 - EPSILON) / stepDegrees);
const minimumLatitudeIndex = Math.floor(-90 / stepDegrees);
const maximumLatitudeIndex = Math.floor((90 - EPSILON) / stepDegrees);
const longitudeIndex = safeInteger(address?.longitudeIndex);
const latitudeIndex = safeInteger(address?.latitudeIndex);
if (longitudeIndex < minimumLongitudeIndex || longitudeIndex > maximumLongitudeIndex) {
throw new Error("grid_graticule_longitude_index_out_of_range");
}
if (latitudeIndex < minimumLatitudeIndex || latitudeIndex > maximumLatitudeIndex) {
throw new Error("grid_graticule_latitude_index_out_of_range");
}
return {
north: latitudeIndex === maximumLatitudeIndex
? null
: graticuleSectorAddress(definition, longitudeIndex, latitudeIndex + 1),
east: graticuleSectorAddress(
definition,
longitudeIndex === maximumLongitudeIndex ? minimumLongitudeIndex : longitudeIndex + 1,
latitudeIndex,
),
south: latitudeIndex === minimumLatitudeIndex
? null
: graticuleSectorAddress(definition, longitudeIndex, latitudeIndex - 1),
west: graticuleSectorAddress(
definition,
longitudeIndex === minimumLongitudeIndex ? maximumLongitudeIndex : longitudeIndex - 1,
latitudeIndex,
),
};
}
function graticuleHierarchyMetrics(definition) {
const stepDegrees = positiveNumber(definition?.stepDegrees, "grid_graticule_step_must_be_positive");
const majorStepDegrees = positiveNumber(
definition?.majorStepDegrees,
"grid_graticule_major_step_must_be_positive",
);
const minorPerMajor = integerMultiple(
majorStepDegrees,
stepDegrees,
"grid_graticule_major_step_must_be_integer_multiple_of_step",
);
const topology = graticuleTopology(
majorStepDegrees,
"grid_graticule_major_step_must_partition_hemisphere",
);
return { ...topology, stepDegrees, majorStepDegrees, minorPerMajor };
}
function graticuleMajorDefinitionToken(definition, metrics = graticuleHierarchyMetrics(definition)) {
return `${graticuleDefinitionToken({ ...definition, stepDegrees: metrics.stepDegrees })}/m${decimalToken(metrics.majorStepDegrees, 6)}`;
}
export function graticuleMajorTileId(definition, longitudeIndex, latitudeIndex) {
const metrics = graticuleHierarchyMetrics(definition);
const longitude = wrapGraticuleIndex(
safeInteger(longitudeIndex),
metrics.longitudeHemisphereCount,
);
const latitude = safeInteger(latitudeIndex);
if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return `grid/${graticuleMajorDefinitionToken(definition, metrics)}/x${signedIndex(longitude)}/y${signedIndex(latitude)}`;
}
function graticuleMajorAddress(definition, longitudeIndex, latitudeIndex) {
const metrics = graticuleHierarchyMetrics(definition);
const longitude = wrapGraticuleIndex(safeInteger(longitudeIndex), metrics.longitudeHemisphereCount);
const latitude = safeInteger(latitudeIndex);
if (latitude < -metrics.latitudeHemisphereCount || latitude >= metrics.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return {
family: "wgs84-graticule-major",
lod: definition.lod,
longitudeIndex: longitude,
latitudeIndex: latitude,
minorPerSide: metrics.minorPerMajor,
id: graticuleMajorTileId(definition, longitude, latitude),
};
}
export function graticuleMajorTileAt(point, definition) {
const { majorStepDegrees } = graticuleHierarchyMetrics(definition);
const longitude = normalizeLongitudeDegrees(point?.longitude);
const latitude = clamp(finite(point?.latitude, 0), -90, 90 - EPSILON);
return graticuleMajorAddress(
definition,
Math.floor(longitude / majorStepDegrees),
Math.floor(latitude / majorStepDegrees),
);
}
export function graticuleMajorTileBounds(address, majorStepDegrees) {
const topology = graticuleTopology(
majorStepDegrees,
"grid_graticule_major_step_must_partition_hemisphere",
);
const longitudeIndex = wrapGraticuleIndex(
safeInteger(address?.longitudeIndex),
topology.longitudeHemisphereCount,
);
const latitudeIndex = safeInteger(address?.latitudeIndex);
if (latitudeIndex < -topology.latitudeHemisphereCount
|| latitudeIndex >= topology.latitudeHemisphereCount) {
throw new Error("grid_graticule_major_latitude_index_out_of_range");
}
return {
west: longitudeIndex * topology.stepDegrees,
east: (longitudeIndex + 1) * topology.stepDegrees,
south: latitudeIndex * topology.stepDegrees,
north: (latitudeIndex + 1) * topology.stepDegrees,
};
}
export function graticuleMajorTileForSector(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
return graticuleMajorAddress(
definition,
Math.floor(safeInteger(address?.longitudeIndex) / metrics.minorPerMajor),
Math.floor(safeInteger(address?.latitudeIndex) / metrics.minorPerMajor),
);
}
export function graticuleMajorTileChildren(address, definition, options) {
const metrics = graticuleHierarchyMetrics(definition);
const major = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex);
const firstLongitudeIndex = major.longitudeIndex * metrics.minorPerMajor;
const firstLatitudeIndex = major.latitudeIndex * metrics.minorPerMajor;
const page = childPageRange(metrics.minorPerMajor ** 2, options);
const children = [];
for (let childIndex = page.start; childIndex < page.end; childIndex += 1) {
children.push(graticuleSectorAddress(
definition,
firstLongitudeIndex + childIndex % metrics.minorPerMajor,
firstLatitudeIndex + Math.floor(childIndex / metrics.minorPerMajor),
));
}
return children;
}
export function graticuleMajorTileNeighbors(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
const tile = graticuleMajorAddress(definition, address?.longitudeIndex, address?.latitudeIndex);
return {
north: tile.latitudeIndex === metrics.latitudeHemisphereCount - 1
? null
: graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex + 1),
east: graticuleMajorAddress(definition, tile.longitudeIndex + 1, tile.latitudeIndex),
south: tile.latitudeIndex === -metrics.latitudeHemisphereCount
? null
: graticuleMajorAddress(definition, tile.longitudeIndex, tile.latitudeIndex - 1),
west: graticuleMajorAddress(definition, tile.longitudeIndex - 1, tile.latitudeIndex),
};
}
export function isGraticuleMajorLineIndex(lineIndex, definition) {
const { minorPerMajor } = graticuleHierarchyMetrics(definition);
return safeInteger(lineIndex, "grid_line_index_must_be_safe_integer") % minorPerMajor === 0;
}
export function isGraticuleMajorLineValue(valueDegrees, definition) {
const { majorStepDegrees } = graticuleHierarchyMetrics(definition);
const ratio = finite(valueDegrees, Number.NaN) / majorStepDegrees;
return Number.isFinite(ratio)
&& Math.abs(ratio - Math.round(ratio)) <= EPSILON * Math.max(1, Math.abs(ratio));
}
function authalicQ(latitudeRadians) {
const sine = Math.sin(latitudeRadians);
const eccentricitySine = WGS84_ECCENTRICITY * sine;
return (1 - WGS84_ECCENTRICITY_SQUARED) * (
sine / (1 - WGS84_ECCENTRICITY_SQUARED * sine * sine)
- Math.log((1 - eccentricitySine) / (1 + eccentricitySine)) / (2 * WGS84_ECCENTRICITY)
);
}
export function geodeticRectangleAreaSquareMeters(bounds) {
const south = clamp(finite(bounds?.south, -90), -90, 90);
const north = clamp(finite(bounds?.north, 90), -90, 90);
if (north <= south) return 0;
const rawWest = finite(bounds?.west, -180);
const rawEast = finite(bounds?.east, 180);
let longitudeSpanDegrees = rawEast - rawWest;
if (Math.abs(longitudeSpanDegrees) >= 360 - EPSILON) longitudeSpanDegrees = 360;
else if (longitudeSpanDegrees < 0) longitudeSpanDegrees += 360;
longitudeSpanDegrees = clamp(longitudeSpanDegrees, 0, 360);
const longitudeSpanRadians = longitudeSpanDegrees * Math.PI / 180;
const southRadians = south * Math.PI / 180;
const northRadians = north * Math.PI / 180;
return WGS84_EQUATORIAL_RADIUS_METERS ** 2
* longitudeSpanRadians
* Math.abs(authalicQ(northRadians) - authalicQ(southRadians))
/ 2;
}
function geodeticBoundsCenter(bounds) {
const width = bounds.east - bounds.west;
return {
longitude: width >= 360 - EPSILON
? 0
: normalizeLongitudeDegrees(bounds.west + width / 2),
latitude: (bounds.south + bounds.north) / 2,
};
}
export function graticuleSectorCenter(address, stepDegrees) {
return geodeticBoundsCenter(graticuleSectorBounds(address, stepDegrees));
}
export function graticuleSectorAreaSquareMeters(address, stepDegrees) {
return geodeticRectangleAreaSquareMeters(graticuleSectorBounds(address, stepDegrees));
}
function graticuleSectorDetail(address, stepDegrees) {
const bounds = graticuleSectorBounds(address, stepDegrees);
return {
address,
bounds,
center: graticuleSectorCenter(address, stepDegrees),
areaSquareMeters: graticuleSectorAreaSquareMeters(address, stepDegrees),
};
}
export function graticuleSectorSummary(address, definition) {
const bounds = graticuleSectorBounds(address, definition.stepDegrees);
const neighbors = Object.fromEntries(
Object.entries(graticuleSectorNeighbors(address, definition))
.map(([direction, neighbor]) => [
direction,
neighbor == null ? null : graticuleSectorDetail(neighbor, definition.stepDegrees),
]),
);
const majorTile = definition?.majorStepDegrees == null
? null
: graticuleMajorTileSummary(graticuleMajorTileForSector(address, definition), definition);
return {
id: address.id,
address,
family: "wgs84-graticule",
lod: address.lod,
label: `L${address.lod} X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`,
indices: {
longitudeIndex: address.longitudeIndex,
latitudeIndex: address.latitudeIndex,
},
bounds,
center: graticuleSectorCenter(address, definition.stepDegrees),
areaSquareMeters: graticuleSectorAreaSquareMeters(address, definition.stepDegrees),
neighbors,
majorTile,
parentMajorTile: majorTile,
};
}
export function graticuleMajorTileCenter(address, majorStepDegrees) {
return geodeticBoundsCenter(graticuleMajorTileBounds(address, majorStepDegrees));
}
export function graticuleMajorTileAreaSquareMeters(address, majorStepDegrees) {
return geodeticRectangleAreaSquareMeters(graticuleMajorTileBounds(address, majorStepDegrees));
}
function graticuleMajorTileDetail(address, majorStepDegrees) {
const bounds = graticuleMajorTileBounds(address, majorStepDegrees);
return {
address,
bounds,
center: graticuleMajorTileCenter(address, majorStepDegrees),
areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, majorStepDegrees),
};
}
export function graticuleMajorTileSummary(address, definition) {
const metrics = graticuleHierarchyMetrics(definition);
const bounds = graticuleMajorTileBounds(address, metrics.majorStepDegrees);
const neighbors = Object.fromEntries(
Object.entries(graticuleMajorTileNeighbors(address, definition))
.map(([direction, neighbor]) => [
direction,
neighbor == null ? null : graticuleMajorTileDetail(neighbor, metrics.majorStepDegrees),
]),
);
return {
id: address.id,
address,
family: "wgs84-graticule-major",
lod: address.lod,
label: `L${address.lod} TILE X${signedIndex(address.longitudeIndex)} Y${signedIndex(address.latitudeIndex)}`,
indices: {
longitudeIndex: address.longitudeIndex,
latitudeIndex: address.latitudeIndex,
},
minorPerSide: metrics.minorPerMajor,
childCount: safeInteger(metrics.minorPerMajor ** 2, "grid_child_count_must_be_safe_integer"),
bounds,
center: graticuleMajorTileCenter(address, metrics.majorStepDegrees),
areaSquareMeters: graticuleMajorTileAreaSquareMeters(address, metrics.majorStepDegrees),
neighbors,
};
}
export function splitLongitudeRange(west, east) {
const rawWest = finite(west, -180);
const rawEast = finite(east, 180);
+69
View File
@@ -983,6 +983,75 @@ textarea {
text-align: right;
}
.catalog-map-grid-sector {
display: grid;
min-width: 0;
gap: 0.55rem;
border-top: 1px solid color-mix(in srgb, var(--nodedc-glass-outline) 44%, transparent);
padding-top: 0.7rem;
}
.catalog-map-grid-sector__facts,
.catalog-map-grid-sector__volume {
display: grid;
min-width: 0;
gap: 0.35rem;
}
.catalog-map-grid-sector__facts .nodedc-control-row__control,
.catalog-map-grid-sector__volume .nodedc-control-row__control {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
}
.catalog-map-grid-sector__relation,
.catalog-map-grid-sector__neighbor,
.catalog-map-grid-sector__volume {
display: grid;
min-width: 0;
gap: 0.4rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-glass-control-bg);
padding: 0.62rem;
}
.catalog-map-grid-sector__relation > small,
.catalog-map-grid-sector__neighbor > code,
.catalog-map-grid-sector__volume > code {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
}
.catalog-map-grid-sector__relation > code,
.catalog-map-grid-sector__neighbor > code,
.catalog-map-grid-sector__volume > code {
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
}
.catalog-map-grid-sector__neighbors {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.catalog-map-grid-sector__volume-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.4rem;
}
.catalog-map-grid-sector__neighbor {
align-content: start;
}
.catalog-map-grid-sector__volume[data-enabled] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 28%, transparent);
}
.catalog-map-subject-card__tab-panel {
display: grid;
gap: 0.65rem;