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
+103 -11
View File
@@ -536,7 +536,8 @@ const GRID_LOD_PROFILE_KEYS = new Set([
"lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters",
"dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters",
"crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor",
"graticuleOpacity",
"graticuleOpacity", "majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier",
"volumeEnabled", "volumeMinimumHeightMeters", "volumeMaximumHeightMeters", "volumeBandHeightMeters",
]);
const GRID_LOD_PROFILE_INPUT_KEYS = new Set([...GRID_LOD_PROFILE_KEYS, "lineWidthPx"]);
@@ -561,6 +562,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 7, dotsColor: "#ffffff", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300,
}),
Object.freeze({
maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -568,6 +571,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 80, dotsColor: "#ffffff", dotsOpacity: 22,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -575,6 +580,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#ffffff", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -582,6 +589,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#fcfdfd", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
Object.freeze({
maxHeightKm: 3_000, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
@@ -589,14 +598,30 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#9c9c9c", dotsOpacity: 58,
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8,
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
}),
]);
function promoteLegacyGridLodProfiles(settings) {
return DEFAULT_GRID_LOD_PROFILES.map((fallback, index) => {
const number = index + 1;
// Keep migration provenance: sector-v2 extension fields must stay absent
// until validateGridLodProfiles applies mode-aware defaults. Spreading the
// new fallback here would make an old flat payload look explicit and
// could reject it for a hierarchy it never configured.
const {
majorLinesEnabled: _majorLinesEnabled,
majorLabelsEnabled: _majorLabelsEnabled,
majorLineWidthMultiplier: _majorLineWidthMultiplier,
volumeEnabled: _volumeEnabled,
volumeMinimumHeightMeters: _volumeMinimumHeightMeters,
volumeMaximumHeightMeters: _volumeMaximumHeightMeters,
volumeBandHeightMeters: _volumeBandHeightMeters,
...legacyFallback
} = fallback;
return {
...fallback,
...legacyFallback,
maxHeightKm: settings[`gridLod${number}MaxHeightKm`] ?? fallback.maxHeightKm,
stepKm: settings[`gridLod${number}StepKm`] ?? fallback.stepKm,
mode: settings[`gridLod${number}Mode`] ?? fallback.mode,
@@ -631,13 +656,30 @@ function validateGridLodProfiles(value) {
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`);
}
const legacyProfile = Object.hasOwn(profile, "lineWidthPx");
const normalized = legacyProfile ? {
const normalized = {
...profile,
graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees,
graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx,
graticuleColor: profile.graticuleColor ?? profile.lineColor,
graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity,
} : { ...profile };
...(legacyProfile ? {
graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees,
graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx,
graticuleColor: profile.graticuleColor ?? profile.lineColor,
graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity,
} : {}),
majorLinesEnabled: profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled,
majorLabelsEnabled: profile.majorLabelsEnabled ?? (
(profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled)
? DEFAULT_GRID_LOD_PROFILES[index].majorLabelsEnabled
: false
),
majorLineWidthMultiplier: profile.majorLineWidthMultiplier ?? DEFAULT_GRID_LOD_PROFILES[index].majorLineWidthMultiplier,
volumeEnabled: profile.volumeEnabled ?? (
(profile.mode ?? DEFAULT_GRID_LOD_PROFILES[index].mode) === "3d"
? DEFAULT_GRID_LOD_PROFILES[index].volumeEnabled
: false
),
volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMinimumHeightMeters,
volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMaximumHeightMeters,
volumeBandHeightMeters: profile.volumeBandHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeBandHeightMeters,
};
delete normalized.lineWidthPx;
if (GRID_LOD_PROFILE_KEYS.size !== Object.keys(normalized).length
|| [...GRID_LOD_PROFILE_KEYS].some((key) => !Object.hasOwn(normalized, key))) {
@@ -649,19 +691,62 @@ function validateGridLodProfiles(value) {
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_maxHeightKm_order`);
}
previousMaxHeightKm = maxHeightKm;
if (!['3d', 'graticule'].includes(normalized.mode)) throw applicationError(code("mode"));
if (!["3d", "graticule"].includes(normalized.mode)) throw applicationError(code("mode"));
const stepKm = requireNumber(normalized.stepKm, 0.1, 5_000, code("stepKm"));
const tileSizeKm = requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm"));
const graticuleStepDegrees = requireNumber(
normalized.graticuleStepDegrees,
0.1,
180,
code("graticuleStepDegrees"),
);
const radiusKm = requireNumber(normalized.radiusKm, 1, 100_000, code("radiusKm"));
if (radiusKm / stepKm > MAX_LOCAL_GRID_INDEX) {
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_workload`);
}
const hasExplicitMajorPolicy = ["majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier"]
.some((key) => Object.hasOwn(profile, key));
let majorLinesEnabled = requireBoolean(normalized.majorLinesEnabled, code("majorLinesEnabled"));
let majorLabelsEnabled = requireBoolean(normalized.majorLabelsEnabled, code("majorLabelsEnabled"));
if (majorLabelsEnabled && !majorLinesEnabled) {
throw applicationError(code("majorLabelsEnabled_requires_majorLinesEnabled"));
}
if (normalized.mode === "3d" && majorLinesEnabled) {
const minorPerMajor = tileSizeKm / stepKm;
if (minorPerMajor < 1 || Math.abs(minorPerMajor - Math.round(minorPerMajor))
> 1e-9 * Math.max(1, Math.abs(minorPerMajor))) {
if (hasExplicitMajorPolicy) throw applicationError(code("tileSizeKm_step_ratio"));
majorLinesEnabled = false;
majorLabelsEnabled = false;
}
}
if (normalized.mode === "graticule" && majorLinesEnabled) {
const majorLatitudeHemisphereSteps = 90 / (graticuleStepDegrees * 5);
if (Math.abs(majorLatitudeHemisphereSteps - Math.round(majorLatitudeHemisphereSteps))
> 1e-9 * Math.max(1, Math.abs(majorLatitudeHemisphereSteps))) {
if (hasExplicitMajorPolicy) throw applicationError(code("graticuleStepDegrees_major_partition"));
majorLinesEnabled = false;
majorLabelsEnabled = false;
}
}
const volumeEnabled = requireBoolean(normalized.volumeEnabled, code("volumeEnabled"));
if (volumeEnabled && normalized.mode !== "3d") {
throw applicationError(code("volumeEnabled_mode"));
}
const volumeMinimumHeightMeters = requireNumber(normalized.volumeMinimumHeightMeters, -1_000, 10_000, code("volumeMinimumHeightMeters"));
const volumeMaximumHeightMeters = requireNumber(normalized.volumeMaximumHeightMeters, -1_000, 10_000, code("volumeMaximumHeightMeters"));
const volumeBandHeightMeters = requireNumber(normalized.volumeBandHeightMeters, 1, 10_000, code("volumeBandHeightMeters"));
if (volumeMaximumHeightMeters <= volumeMinimumHeightMeters) throw applicationError(code("volumeHeightOrder"));
if (volumeBandHeightMeters > volumeMaximumHeightMeters - volumeMinimumHeightMeters) {
throw applicationError(code("volumeBandHeightMeters_span"));
}
return {
maxHeightKm,
stepKm,
mode: normalized.mode,
heightMeters: requireNumber(normalized.heightMeters, 0, 5_000, code("heightMeters")),
max3dViewAngleDegrees: requireNumber(normalized.max3dViewAngleDegrees, 30, 170, code("max3dViewAngleDegrees")),
tileSizeKm: requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm")),
tileSizeKm,
radiusKm,
lineDiameterMeters: requireNumber(normalized.lineDiameterMeters, 1, 100, code("lineDiameterMeters")),
lineColor: requireHex(normalized.lineColor, code("lineColor")),
@@ -675,10 +760,17 @@ function validateGridLodProfiles(value) {
crossesWidthMeters: requireNumber(normalized.crossesWidthMeters, 1, 500, code("crossesWidthMeters")),
crossesColor: requireHex(normalized.crossesColor, code("crossesColor")),
crossesOpacity: requireNumber(normalized.crossesOpacity, 0, 100, code("crossesOpacity")),
graticuleStepDegrees: requireNumber(normalized.graticuleStepDegrees, 0.1, 180, code("graticuleStepDegrees")),
graticuleStepDegrees,
graticuleLineWidthPx: requireNumber(normalized.graticuleLineWidthPx, 1, 3, code("graticuleLineWidthPx")),
graticuleColor: requireHex(normalized.graticuleColor, code("graticuleColor")),
graticuleOpacity: requireNumber(normalized.graticuleOpacity, 0, 100, code("graticuleOpacity")),
majorLinesEnabled,
majorLabelsEnabled,
majorLineWidthMultiplier: requireNumber(normalized.majorLineWidthMultiplier, 1, 8, code("majorLineWidthMultiplier")),
volumeEnabled,
volumeMinimumHeightMeters,
volumeMaximumHeightMeters,
volumeBandHeightMeters,
};
});
}