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,
};
});
}
+7
View File
@@ -485,6 +485,13 @@ const mapPageSettingsPatchInputSchema = {
graticuleLineWidthPx: { type: "number", minimum: 1, maximum: 3 },
graticuleColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
graticuleOpacity: { type: "number", minimum: 0, maximum: 100 },
majorLinesEnabled: { type: "boolean" },
majorLabelsEnabled: { type: "boolean" },
majorLineWidthMultiplier: { type: "number", minimum: 1, maximum: 8 },
volumeEnabled: { type: "boolean" },
volumeMinimumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 },
volumeMaximumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 },
volumeBandHeightMeters: { type: "number", minimum: 1, maximum: 10000 },
},
},
},
+98
View File
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { DEFAULT_GRID_LOD_PROFILES } from "../apps/catalog/src/mapGridPolicy.mjs";
const seed = JSON.parse(await readFile(new URL("../runtime-seed/page-layouts/map.json", import.meta.url), "utf8"));
const serverSource = await readFile(new URL("./catalog-server.mjs", import.meta.url), "utf8");
const mcpSource = await readFile(new URL("./foundry-mcp.mjs", import.meta.url), "utf8");
const extensionKeys = [
"majorLinesEnabled",
"majorLabelsEnabled",
"majorLineWidthMultiplier",
"volumeEnabled",
"volumeMinimumHeightMeters",
"volumeMaximumHeightMeters",
"volumeBandHeightMeters",
];
function applicationError(code, statusCode = 400) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
const validators = {
applicationError,
isObject: (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value),
requireNumber(value, minimum, maximum, code) {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
throw applicationError(code);
}
return value;
},
requireBoolean(value, code) {
if (typeof value !== "boolean") throw applicationError(code);
return value;
},
requireHex(value, code) {
const normalized = String(value || "");
if (!/^#[0-9a-f]{6}$/i.test(normalized)) throw applicationError(code);
return normalized.toLowerCase();
},
};
function loadServerGridContract() {
const start = serverSource.indexOf("const GRID_LOD_PROFILE_KEYS");
const end = serverSource.indexOf("function validateMapPageSettingsPatch");
assert.ok(start >= 0 && end > start, "server grid contract source boundaries");
const context = vm.createContext({ ...validators });
const source = `${serverSource.slice(start, end)}\n;globalThis.gridContract = { DEFAULT_GRID_LOD_PROFILES, promoteLegacyGridLodProfiles, validateGridLodProfiles };`;
new vm.Script(source, { filename: "catalog-server.grid-contract.mjs" }).runInContext(context);
return context.gridContract;
}
const serverGrid = loadServerGridContract();
test("seed, client defaults and server promotion keep the sector-v2 profile contract identical", () => {
assert.deepEqual(seed.settings.gridLodProfiles, DEFAULT_GRID_LOD_PROFILES);
const legacyProfiles = seed.settings.gridLodProfiles.map((profile) => {
const legacy = { ...profile };
for (const key of extensionKeys) delete legacy[key];
return legacy;
});
assert.deepEqual(
structuredClone(serverGrid.validateGridLodProfiles(legacyProfiles)),
DEFAULT_GRID_LOD_PROFILES,
);
});
test("flat layouts migrate mode-aware extensions while explicit invalid hierarchy is rejected", () => {
const flatSettings = {
...seed.settings,
gridLodProfiles: undefined,
gridLod1StepKm: 3,
gridTileSizeKm: 10,
gridLod2Mode: "graticule",
};
const migrated = structuredClone(serverGrid.validateGridLodProfiles(
serverGrid.promoteLegacyGridLodProfiles(flatSettings),
));
assert.equal(migrated[0].majorLinesEnabled, false);
assert.equal(migrated[0].majorLabelsEnabled, false);
assert.equal(migrated[1].mode, "graticule");
assert.equal(migrated[1].volumeEnabled, false);
const invalidExplicit = structuredClone(DEFAULT_GRID_LOD_PROFILES);
invalidExplicit[0].stepKm = 3;
invalidExplicit[0].tileSizeKm = 10;
assert.throws(
() => serverGrid.validateGridLodProfiles(invalidExplicit),
/invalid_map_grid_lod_profile_1_tileSizeKm_step_ratio/,
);
});
test("Foundry MCP exposes every persisted sector-v2 profile field", () => {
for (const key of extensionKeys) assert.match(mcpSource, new RegExp(`\\b${key}: \\{`));
});