feat(foundry): render geozones as independent map layer
This commit is contained in:
+48
-13
@@ -1166,11 +1166,53 @@ async function inspectFoundryConsumerReaderGrant(target, { generation = 1 } = {}
|
||||
return { product: planned.product, readerGrantAction: "ensure", readerGrantGeneration: generation };
|
||||
}
|
||||
|
||||
function runtimePointGeometry(value) {
|
||||
if (!isObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) return null;
|
||||
const [longitude, latitude] = value.coordinates;
|
||||
if (typeof longitude !== "number" || !Number.isFinite(longitude) || typeof latitude !== "number" || !Number.isFinite(latitude)) return null;
|
||||
return { type: "Point", coordinates: [longitude, latitude] };
|
||||
function runtimePosition(value) {
|
||||
if (!Array.isArray(value) || value.length !== 2) return null;
|
||||
const [longitude, latitude] = value;
|
||||
if (
|
||||
typeof longitude !== "number"
|
||||
|| !Number.isFinite(longitude)
|
||||
|| longitude < -180
|
||||
|| longitude > 180
|
||||
|| typeof latitude !== "number"
|
||||
|| !Number.isFinite(latitude)
|
||||
|| latitude < -90
|
||||
|| latitude > 90
|
||||
) return null;
|
||||
return [longitude, latitude];
|
||||
}
|
||||
|
||||
function runtimeLinearRing(value) {
|
||||
if (!Array.isArray(value) || value.length < 4 || value.length > 10_000) return null;
|
||||
const positions = value.map(runtimePosition);
|
||||
if (positions.some((position) => !position)) return null;
|
||||
const first = positions[0];
|
||||
const last = positions.at(-1);
|
||||
if (!last || first[0] !== last[0] || first[1] !== last[1]) return null;
|
||||
return positions;
|
||||
}
|
||||
|
||||
function runtimePolygonCoordinates(value) {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 256) return null;
|
||||
const rings = value.map(runtimeLinearRing);
|
||||
return rings.some((ring) => !ring) ? null : rings;
|
||||
}
|
||||
|
||||
function runtimeGeometry(value) {
|
||||
if (!isObject(value) || JSON.stringify(value).length > 196_608) return null;
|
||||
if (value.type === "Point") {
|
||||
const coordinates = runtimePosition(value.coordinates);
|
||||
return coordinates ? { type: "Point", coordinates } : null;
|
||||
}
|
||||
if (value.type === "Polygon") {
|
||||
const coordinates = runtimePolygonCoordinates(value.coordinates);
|
||||
return coordinates ? { type: "Polygon", coordinates } : null;
|
||||
}
|
||||
if (value.type === "MultiPolygon" && Array.isArray(value.coordinates) && value.coordinates.length >= 1 && value.coordinates.length <= 256) {
|
||||
const coordinates = value.coordinates.map(runtimePolygonCoordinates);
|
||||
return coordinates.some((polygon) => !polygon) ? null : { type: "MultiPolygon", coordinates };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const RUNTIME_SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
|
||||
@@ -1203,7 +1245,7 @@ function sanitizeRuntimeFact(value, binding) {
|
||||
observedAt: value.observedAt,
|
||||
receivedAt: value.receivedAt,
|
||||
attributes,
|
||||
geometry: runtimePointGeometry(value.geometry),
|
||||
geometry: runtimeGeometry(value.geometry),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1285,23 +1327,16 @@ function sanitizeRuntimePatch(value, binding) {
|
||||
const fact = sanitizeRuntimeFact(operation.fact, binding);
|
||||
return fact ? [{ op: "upsert", fact }] : [];
|
||||
}
|
||||
// Current EDP v1 emits only upserts. Foundry already understands the
|
||||
// canonical removal shape so a future versioned product can revoke a
|
||||
// subject without turning a transient stream failure into deletion.
|
||||
if (
|
||||
operation.op === "remove"
|
||||
&& isRuntimeIdentifier(operation.sourceId)
|
||||
&& isRuntimeIdentifier(operation.semanticType)
|
||||
&& binding.semanticTypes.includes(operation.semanticType)
|
||||
&& isRuntimeTimestamp(operation.removedAt)
|
||||
&& ["tombstone", "revoked"].includes(operation.reason)
|
||||
) {
|
||||
return [{
|
||||
op: "remove",
|
||||
sourceId: operation.sourceId,
|
||||
semanticType: operation.semanticType,
|
||||
removedAt: operation.removedAt,
|
||||
reason: operation.reason,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -49,7 +49,7 @@ function safeStatusFromFact(fact, policy, nowMs) {
|
||||
if (typeof sourceStatus !== "string" || !statusContract.allowedValues.includes(sourceStatus)) {
|
||||
throw consumerError("data_product_consumer_fact_status_invalid", 502);
|
||||
}
|
||||
if (statusContract.freshness === "none" || policy.terminalStatuses.includes(sourceStatus)) {
|
||||
if (policy.freshness === "none" || policy.terminalStatuses.includes(sourceStatus)) {
|
||||
return sourceStatus;
|
||||
}
|
||||
const observedAt = Date.parse(String(fact?.observedAt || ""));
|
||||
@@ -60,6 +60,7 @@ function safeStatusFromFact(fact, policy, nowMs) {
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active";
|
||||
if (policy.terminalStatuses.includes(normalized)) return normalized;
|
||||
if (policy.freshness === "none") return normalized;
|
||||
const observedAt = Date.parse(String(fact?.observedAt || ""));
|
||||
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
|
||||
return normalized;
|
||||
@@ -144,7 +145,14 @@ function validatePolicy(policy, product) {
|
||||
const statusContract = policy.statusContract === undefined
|
||||
? null
|
||||
: validateStatusContract(policy.statusContract);
|
||||
const staleDisabled = statusContract?.freshness === "none";
|
||||
const freshness = policy.freshness ?? statusContract?.freshness ?? "observed-at";
|
||||
if (!["none", "observed-at"].includes(freshness)) {
|
||||
throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
}
|
||||
if (statusContract && statusContract.freshness !== freshness) {
|
||||
throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
}
|
||||
const staleDisabled = freshness === "none";
|
||||
if (
|
||||
(staleDisabled && policy.staleAfterMs !== null)
|
||||
|| (!staleDisabled && (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000))
|
||||
@@ -166,6 +174,7 @@ function validatePolicy(policy, product) {
|
||||
version: String(policy.version || ""),
|
||||
dataProductId: product.id,
|
||||
productVersion: product.version,
|
||||
freshness,
|
||||
staleAfterMs: policy.staleAfterMs,
|
||||
terminalStatuses: [...terminalStatuses],
|
||||
...(statusContract ? { statusContract } : {}),
|
||||
|
||||
+35
-19
@@ -174,26 +174,42 @@ const mapPresentationProfileInputSchema = {
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor",
|
||||
"outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters",
|
||||
],
|
||||
properties: {
|
||||
variant: {
|
||||
type: "string",
|
||||
enum: ["elevated-spike"],
|
||||
description: "Renderer-neutral presentation preset interpreted by the active Map adapter.",
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor",
|
||||
"outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters",
|
||||
],
|
||||
properties: {
|
||||
variant: { type: "string", enum: ["elevated-spike"] },
|
||||
stemHeightMeters: { type: "number", minimum: 1, maximum: 100000 },
|
||||
headSizePx: { type: "number", minimum: 1, maximum: 64 },
|
||||
stemWidthPx: { type: "number", minimum: 0.25, maximum: 16 },
|
||||
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
outlineOpacity: { type: "number", minimum: 0, maximum: 1 },
|
||||
outlineWidthPx: { type: "number", minimum: 0, maximum: 8 },
|
||||
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
|
||||
},
|
||||
},
|
||||
stemHeightMeters: { type: "number", minimum: 1, maximum: 100000 },
|
||||
headSizePx: { type: "number", minimum: 1, maximum: 64 },
|
||||
stemWidthPx: { type: "number", minimum: 0.25, maximum: 16 },
|
||||
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
outlineOpacity: { type: "number", minimum: 0, maximum: 1 },
|
||||
outlineWidthPx: { type: "number", minimum: 0, maximum: 8 },
|
||||
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["variant", "outlineColor", "outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters"],
|
||||
properties: {
|
||||
variant: {
|
||||
type: "string",
|
||||
enum: ["surface-fill"],
|
||||
description: "Provider-neutral filled surface interpreted by the active Map adapter.",
|
||||
},
|
||||
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
||||
outlineOpacity: { type: "number", minimum: 0, maximum: 1 },
|
||||
outlineWidthPx: { type: "number", minimum: 0, maximum: 8 },
|
||||
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
facets: {
|
||||
type: "array",
|
||||
|
||||
@@ -92,16 +92,25 @@ function normalizeLabel(value) {
|
||||
function normalizeTarget(value) {
|
||||
object(value, "invalid_map_presentation_profile_target");
|
||||
onlyKeys(value, TARGET_KEYS, "invalid_map_presentation_profile_target_fields");
|
||||
const shared = {
|
||||
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_target_outline"),
|
||||
outlineOpacity: number(value.outlineOpacity, 0, 1, "invalid_map_presentation_profile_target_outline_opacity"),
|
||||
outlineWidthPx: number(value.outlineWidthPx, 0, 8, "invalid_map_presentation_profile_target_outline_width"),
|
||||
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_target_lod"),
|
||||
};
|
||||
if (value.variant === "surface-fill") {
|
||||
if (value.stemHeightMeters !== undefined || value.headSizePx !== undefined || value.stemWidthPx !== undefined) {
|
||||
fail("invalid_map_presentation_profile_surface_target_fields");
|
||||
}
|
||||
return { variant: "surface-fill", ...shared };
|
||||
}
|
||||
if (value.variant !== "elevated-spike") fail("invalid_map_presentation_profile_target_variant");
|
||||
return {
|
||||
variant: "elevated-spike",
|
||||
stemHeightMeters: number(value.stemHeightMeters, 1, 100_000, "invalid_map_presentation_profile_target_stem_height"),
|
||||
headSizePx: number(value.headSizePx, 1, 64, "invalid_map_presentation_profile_target_head_size"),
|
||||
stemWidthPx: number(value.stemWidthPx, 0.25, 16, "invalid_map_presentation_profile_target_stem_width"),
|
||||
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_target_outline"),
|
||||
outlineOpacity: number(value.outlineOpacity, 0, 1, "invalid_map_presentation_profile_target_outline_opacity"),
|
||||
outlineWidthPx: number(value.outlineWidthPx, 0, 8, "invalid_map_presentation_profile_target_outline_width"),
|
||||
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_target_lod"),
|
||||
...shared,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ const catalogStyles = await readFile(new URL("../apps/catalog/src/styles.css", i
|
||||
test("canonical moving-object profile is provider-neutral and internally consistent", () => {
|
||||
assert.equal(registry.schemaVersion, "nodedc.map-presentation-profiles/v1");
|
||||
const profiles = normalizeMapPresentationProfiles(registry.profiles);
|
||||
assert.equal(profiles.length, 1);
|
||||
const profile = profiles[0];
|
||||
assert.equal(profiles.length, 2);
|
||||
const profile = profiles.find((item) => item.id === "map.moving-object.operational.default");
|
||||
assert.ok(profile);
|
||||
assert.equal(profile.id, "map.moving-object.operational.default");
|
||||
assert.deepEqual(profile.semanticTypes, ["map.moving_object"]);
|
||||
assert.equal(profile.target.variant, "elevated-spike");
|
||||
@@ -37,6 +38,18 @@ test("canonical moving-object profile is provider-neutral and internally consist
|
||||
assert.equal(JSON.stringify(profile).includes("Неизвестно"), false);
|
||||
});
|
||||
|
||||
test("canonical zone profile renders provider-neutral surfaces", () => {
|
||||
const profiles = normalizeMapPresentationProfiles(registry.profiles);
|
||||
const profile = profiles.find((item) => item.id === "map.zone.operational.default");
|
||||
assert.ok(profile);
|
||||
assert.deepEqual(profile.semanticTypes, ["map.zone"]);
|
||||
assert.equal(profile.target.variant, "surface-fill");
|
||||
assert.equal(profile.label.mode, "attributes");
|
||||
assert.deepEqual(profile.facets.map((facet) => facet.field), ["geometry_kind", "source_kind"]);
|
||||
assert.equal(JSON.stringify(profile).toLowerCase().includes("gelios"), false);
|
||||
assert.equal(JSON.stringify(profile).includes("http"), false);
|
||||
});
|
||||
|
||||
test("fleet filter window is one compact vertical list without group rows or All", () => {
|
||||
assert.match(mapFixtureSource, /catalog-map-fixture__target-filter-list/);
|
||||
assert.doesNotMatch(mapFixtureSource, /catalog-map-fixture__target-profile/);
|
||||
|
||||
Reference in New Issue
Block a user