feat(map): add functional sector grid v2
This commit is contained in:
+103
-11
@@ -536,7 +536,8 @@ const GRID_LOD_PROFILE_KEYS = new Set([
|
||||
"lineDiameterMeters", "lineColor", "lineOpacity", "dotsEnabled", "dotsDiameterMeters",
|
||||
"dotsColor", "dotsOpacity", "crossesEnabled", "crossesLengthMeters", "crossesWidthMeters",
|
||||
"crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor",
|
||||
"graticuleOpacity",
|
||||
"graticuleOpacity", "majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier",
|
||||
"volumeEnabled", "volumeMinimumHeightMeters", "volumeMaximumHeightMeters", "volumeBandHeightMeters",
|
||||
]);
|
||||
|
||||
const GRID_LOD_PROFILE_INPUT_KEYS = new Set([...GRID_LOD_PROFILE_KEYS, "lineWidthPx"]);
|
||||
@@ -561,6 +562,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
|
||||
dotsEnabled: true, dotsDiameterMeters: 7, dotsColor: "#ffffff", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12,
|
||||
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
|
||||
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
@@ -568,6 +571,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
|
||||
dotsEnabled: true, dotsDiameterMeters: 80, dotsColor: "#ffffff", dotsOpacity: 22,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12,
|
||||
majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5,
|
||||
volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
@@ -575,6 +580,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#ffffff", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12,
|
||||
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
|
||||
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
@@ -582,6 +589,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#fcfdfd", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12,
|
||||
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
|
||||
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
|
||||
}),
|
||||
Object.freeze({
|
||||
maxHeightKm: 3_000, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30,
|
||||
@@ -589,14 +598,30 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([
|
||||
dotsEnabled: true, dotsDiameterMeters: 10, dotsColor: "#9c9c9c", dotsOpacity: 58,
|
||||
crossesEnabled: false, crossesLengthMeters: 60, crossesWidthMeters: 10, crossesColor: "#9c9c9c", crossesOpacity: 46,
|
||||
graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8,
|
||||
majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2,
|
||||
volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500,
|
||||
}),
|
||||
]);
|
||||
|
||||
function promoteLegacyGridLodProfiles(settings) {
|
||||
return DEFAULT_GRID_LOD_PROFILES.map((fallback, index) => {
|
||||
const number = index + 1;
|
||||
// Keep migration provenance: sector-v2 extension fields must stay absent
|
||||
// until validateGridLodProfiles applies mode-aware defaults. Spreading the
|
||||
// new fallback here would make an old flat payload look explicit and
|
||||
// could reject it for a hierarchy it never configured.
|
||||
const {
|
||||
majorLinesEnabled: _majorLinesEnabled,
|
||||
majorLabelsEnabled: _majorLabelsEnabled,
|
||||
majorLineWidthMultiplier: _majorLineWidthMultiplier,
|
||||
volumeEnabled: _volumeEnabled,
|
||||
volumeMinimumHeightMeters: _volumeMinimumHeightMeters,
|
||||
volumeMaximumHeightMeters: _volumeMaximumHeightMeters,
|
||||
volumeBandHeightMeters: _volumeBandHeightMeters,
|
||||
...legacyFallback
|
||||
} = fallback;
|
||||
return {
|
||||
...fallback,
|
||||
...legacyFallback,
|
||||
maxHeightKm: settings[`gridLod${number}MaxHeightKm`] ?? fallback.maxHeightKm,
|
||||
stepKm: settings[`gridLod${number}StepKm`] ?? fallback.stepKm,
|
||||
mode: settings[`gridLod${number}Mode`] ?? fallback.mode,
|
||||
@@ -631,13 +656,30 @@ function validateGridLodProfiles(value) {
|
||||
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}`);
|
||||
}
|
||||
const legacyProfile = Object.hasOwn(profile, "lineWidthPx");
|
||||
const normalized = legacyProfile ? {
|
||||
const normalized = {
|
||||
...profile,
|
||||
graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees,
|
||||
graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx,
|
||||
graticuleColor: profile.graticuleColor ?? profile.lineColor,
|
||||
graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity,
|
||||
} : { ...profile };
|
||||
...(legacyProfile ? {
|
||||
graticuleStepDegrees: profile.graticuleStepDegrees ?? DEFAULT_GRID_LOD_PROFILES[index].graticuleStepDegrees,
|
||||
graticuleLineWidthPx: profile.graticuleLineWidthPx ?? profile.lineWidthPx,
|
||||
graticuleColor: profile.graticuleColor ?? profile.lineColor,
|
||||
graticuleOpacity: profile.graticuleOpacity ?? profile.lineOpacity,
|
||||
} : {}),
|
||||
majorLinesEnabled: profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled,
|
||||
majorLabelsEnabled: profile.majorLabelsEnabled ?? (
|
||||
(profile.majorLinesEnabled ?? DEFAULT_GRID_LOD_PROFILES[index].majorLinesEnabled)
|
||||
? DEFAULT_GRID_LOD_PROFILES[index].majorLabelsEnabled
|
||||
: false
|
||||
),
|
||||
majorLineWidthMultiplier: profile.majorLineWidthMultiplier ?? DEFAULT_GRID_LOD_PROFILES[index].majorLineWidthMultiplier,
|
||||
volumeEnabled: profile.volumeEnabled ?? (
|
||||
(profile.mode ?? DEFAULT_GRID_LOD_PROFILES[index].mode) === "3d"
|
||||
? DEFAULT_GRID_LOD_PROFILES[index].volumeEnabled
|
||||
: false
|
||||
),
|
||||
volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMinimumHeightMeters,
|
||||
volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMaximumHeightMeters,
|
||||
volumeBandHeightMeters: profile.volumeBandHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeBandHeightMeters,
|
||||
};
|
||||
delete normalized.lineWidthPx;
|
||||
if (GRID_LOD_PROFILE_KEYS.size !== Object.keys(normalized).length
|
||||
|| [...GRID_LOD_PROFILE_KEYS].some((key) => !Object.hasOwn(normalized, key))) {
|
||||
@@ -649,19 +691,62 @@ function validateGridLodProfiles(value) {
|
||||
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_maxHeightKm_order`);
|
||||
}
|
||||
previousMaxHeightKm = maxHeightKm;
|
||||
if (!['3d', 'graticule'].includes(normalized.mode)) throw applicationError(code("mode"));
|
||||
if (!["3d", "graticule"].includes(normalized.mode)) throw applicationError(code("mode"));
|
||||
const stepKm = requireNumber(normalized.stepKm, 0.1, 5_000, code("stepKm"));
|
||||
const tileSizeKm = requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm"));
|
||||
const graticuleStepDegrees = requireNumber(
|
||||
normalized.graticuleStepDegrees,
|
||||
0.1,
|
||||
180,
|
||||
code("graticuleStepDegrees"),
|
||||
);
|
||||
const radiusKm = requireNumber(normalized.radiusKm, 1, 100_000, code("radiusKm"));
|
||||
if (radiusKm / stepKm > MAX_LOCAL_GRID_INDEX) {
|
||||
throw applicationError(`invalid_map_grid_lod_profile_${index + 1}_workload`);
|
||||
}
|
||||
const hasExplicitMajorPolicy = ["majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier"]
|
||||
.some((key) => Object.hasOwn(profile, key));
|
||||
let majorLinesEnabled = requireBoolean(normalized.majorLinesEnabled, code("majorLinesEnabled"));
|
||||
let majorLabelsEnabled = requireBoolean(normalized.majorLabelsEnabled, code("majorLabelsEnabled"));
|
||||
if (majorLabelsEnabled && !majorLinesEnabled) {
|
||||
throw applicationError(code("majorLabelsEnabled_requires_majorLinesEnabled"));
|
||||
}
|
||||
if (normalized.mode === "3d" && majorLinesEnabled) {
|
||||
const minorPerMajor = tileSizeKm / stepKm;
|
||||
if (minorPerMajor < 1 || Math.abs(minorPerMajor - Math.round(minorPerMajor))
|
||||
> 1e-9 * Math.max(1, Math.abs(minorPerMajor))) {
|
||||
if (hasExplicitMajorPolicy) throw applicationError(code("tileSizeKm_step_ratio"));
|
||||
majorLinesEnabled = false;
|
||||
majorLabelsEnabled = false;
|
||||
}
|
||||
}
|
||||
if (normalized.mode === "graticule" && majorLinesEnabled) {
|
||||
const majorLatitudeHemisphereSteps = 90 / (graticuleStepDegrees * 5);
|
||||
if (Math.abs(majorLatitudeHemisphereSteps - Math.round(majorLatitudeHemisphereSteps))
|
||||
> 1e-9 * Math.max(1, Math.abs(majorLatitudeHemisphereSteps))) {
|
||||
if (hasExplicitMajorPolicy) throw applicationError(code("graticuleStepDegrees_major_partition"));
|
||||
majorLinesEnabled = false;
|
||||
majorLabelsEnabled = false;
|
||||
}
|
||||
}
|
||||
const volumeEnabled = requireBoolean(normalized.volumeEnabled, code("volumeEnabled"));
|
||||
if (volumeEnabled && normalized.mode !== "3d") {
|
||||
throw applicationError(code("volumeEnabled_mode"));
|
||||
}
|
||||
const volumeMinimumHeightMeters = requireNumber(normalized.volumeMinimumHeightMeters, -1_000, 10_000, code("volumeMinimumHeightMeters"));
|
||||
const volumeMaximumHeightMeters = requireNumber(normalized.volumeMaximumHeightMeters, -1_000, 10_000, code("volumeMaximumHeightMeters"));
|
||||
const volumeBandHeightMeters = requireNumber(normalized.volumeBandHeightMeters, 1, 10_000, code("volumeBandHeightMeters"));
|
||||
if (volumeMaximumHeightMeters <= volumeMinimumHeightMeters) throw applicationError(code("volumeHeightOrder"));
|
||||
if (volumeBandHeightMeters > volumeMaximumHeightMeters - volumeMinimumHeightMeters) {
|
||||
throw applicationError(code("volumeBandHeightMeters_span"));
|
||||
}
|
||||
return {
|
||||
maxHeightKm,
|
||||
stepKm,
|
||||
mode: normalized.mode,
|
||||
heightMeters: requireNumber(normalized.heightMeters, 0, 5_000, code("heightMeters")),
|
||||
max3dViewAngleDegrees: requireNumber(normalized.max3dViewAngleDegrees, 30, 170, code("max3dViewAngleDegrees")),
|
||||
tileSizeKm: requireNumber(normalized.tileSizeKm, 1, 50, code("tileSizeKm")),
|
||||
tileSizeKm,
|
||||
radiusKm,
|
||||
lineDiameterMeters: requireNumber(normalized.lineDiameterMeters, 1, 100, code("lineDiameterMeters")),
|
||||
lineColor: requireHex(normalized.lineColor, code("lineColor")),
|
||||
@@ -675,10 +760,17 @@ function validateGridLodProfiles(value) {
|
||||
crossesWidthMeters: requireNumber(normalized.crossesWidthMeters, 1, 500, code("crossesWidthMeters")),
|
||||
crossesColor: requireHex(normalized.crossesColor, code("crossesColor")),
|
||||
crossesOpacity: requireNumber(normalized.crossesOpacity, 0, 100, code("crossesOpacity")),
|
||||
graticuleStepDegrees: requireNumber(normalized.graticuleStepDegrees, 0.1, 180, code("graticuleStepDegrees")),
|
||||
graticuleStepDegrees,
|
||||
graticuleLineWidthPx: requireNumber(normalized.graticuleLineWidthPx, 1, 3, code("graticuleLineWidthPx")),
|
||||
graticuleColor: requireHex(normalized.graticuleColor, code("graticuleColor")),
|
||||
graticuleOpacity: requireNumber(normalized.graticuleOpacity, 0, 100, code("graticuleOpacity")),
|
||||
majorLinesEnabled,
|
||||
majorLabelsEnabled,
|
||||
majorLineWidthMultiplier: requireNumber(normalized.majorLineWidthMultiplier, 1, 8, code("majorLineWidthMultiplier")),
|
||||
volumeEnabled,
|
||||
volumeMinimumHeightMeters,
|
||||
volumeMaximumHeightMeters,
|
||||
volumeBandHeightMeters,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -485,6 +485,13 @@ const mapPageSettingsPatchInputSchema = {
|
||||
graticuleLineWidthPx: { type: "number", minimum: 1, maximum: 3 },
|
||||
graticuleColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
graticuleOpacity: { type: "number", minimum: 0, maximum: 100 },
|
||||
majorLinesEnabled: { type: "boolean" },
|
||||
majorLabelsEnabled: { type: "boolean" },
|
||||
majorLineWidthMultiplier: { type: "number", minimum: 1, maximum: 8 },
|
||||
volumeEnabled: { type: "boolean" },
|
||||
volumeMinimumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 },
|
||||
volumeMaximumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 },
|
||||
volumeBandHeightMeters: { type: "number", minimum: 1, maximum: 10000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { DEFAULT_GRID_LOD_PROFILES } from "../apps/catalog/src/mapGridPolicy.mjs";
|
||||
|
||||
const seed = JSON.parse(await readFile(new URL("../runtime-seed/page-layouts/map.json", import.meta.url), "utf8"));
|
||||
const serverSource = await readFile(new URL("./catalog-server.mjs", import.meta.url), "utf8");
|
||||
const mcpSource = await readFile(new URL("./foundry-mcp.mjs", import.meta.url), "utf8");
|
||||
const extensionKeys = [
|
||||
"majorLinesEnabled",
|
||||
"majorLabelsEnabled",
|
||||
"majorLineWidthMultiplier",
|
||||
"volumeEnabled",
|
||||
"volumeMinimumHeightMeters",
|
||||
"volumeMaximumHeightMeters",
|
||||
"volumeBandHeightMeters",
|
||||
];
|
||||
|
||||
function applicationError(code, statusCode = 400) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
const validators = {
|
||||
applicationError,
|
||||
isObject: (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value),
|
||||
requireNumber(value, minimum, maximum, code) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
|
||||
throw applicationError(code);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
requireBoolean(value, code) {
|
||||
if (typeof value !== "boolean") throw applicationError(code);
|
||||
return value;
|
||||
},
|
||||
requireHex(value, code) {
|
||||
const normalized = String(value || "");
|
||||
if (!/^#[0-9a-f]{6}$/i.test(normalized)) throw applicationError(code);
|
||||
return normalized.toLowerCase();
|
||||
},
|
||||
};
|
||||
|
||||
function loadServerGridContract() {
|
||||
const start = serverSource.indexOf("const GRID_LOD_PROFILE_KEYS");
|
||||
const end = serverSource.indexOf("function validateMapPageSettingsPatch");
|
||||
assert.ok(start >= 0 && end > start, "server grid contract source boundaries");
|
||||
const context = vm.createContext({ ...validators });
|
||||
const source = `${serverSource.slice(start, end)}\n;globalThis.gridContract = { DEFAULT_GRID_LOD_PROFILES, promoteLegacyGridLodProfiles, validateGridLodProfiles };`;
|
||||
new vm.Script(source, { filename: "catalog-server.grid-contract.mjs" }).runInContext(context);
|
||||
return context.gridContract;
|
||||
}
|
||||
|
||||
const serverGrid = loadServerGridContract();
|
||||
|
||||
test("seed, client defaults and server promotion keep the sector-v2 profile contract identical", () => {
|
||||
assert.deepEqual(seed.settings.gridLodProfiles, DEFAULT_GRID_LOD_PROFILES);
|
||||
const legacyProfiles = seed.settings.gridLodProfiles.map((profile) => {
|
||||
const legacy = { ...profile };
|
||||
for (const key of extensionKeys) delete legacy[key];
|
||||
return legacy;
|
||||
});
|
||||
assert.deepEqual(
|
||||
structuredClone(serverGrid.validateGridLodProfiles(legacyProfiles)),
|
||||
DEFAULT_GRID_LOD_PROFILES,
|
||||
);
|
||||
});
|
||||
|
||||
test("flat layouts migrate mode-aware extensions while explicit invalid hierarchy is rejected", () => {
|
||||
const flatSettings = {
|
||||
...seed.settings,
|
||||
gridLodProfiles: undefined,
|
||||
gridLod1StepKm: 3,
|
||||
gridTileSizeKm: 10,
|
||||
gridLod2Mode: "graticule",
|
||||
};
|
||||
const migrated = structuredClone(serverGrid.validateGridLodProfiles(
|
||||
serverGrid.promoteLegacyGridLodProfiles(flatSettings),
|
||||
));
|
||||
assert.equal(migrated[0].majorLinesEnabled, false);
|
||||
assert.equal(migrated[0].majorLabelsEnabled, false);
|
||||
assert.equal(migrated[1].mode, "graticule");
|
||||
assert.equal(migrated[1].volumeEnabled, false);
|
||||
|
||||
const invalidExplicit = structuredClone(DEFAULT_GRID_LOD_PROFILES);
|
||||
invalidExplicit[0].stepKm = 3;
|
||||
invalidExplicit[0].tileSizeKm = 10;
|
||||
assert.throws(
|
||||
() => serverGrid.validateGridLodProfiles(invalidExplicit),
|
||||
/invalid_map_grid_lod_profile_1_tileSizeKm_step_ratio/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Foundry MCP exposes every persisted sector-v2 profile field", () => {
|
||||
for (const key of extensionKeys) assert.match(mcpSource, new RegExp(`\\b${key}: \\{`));
|
||||
});
|
||||
Reference in New Issue
Block a user