feat(foundry): close the operational map data loop
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||
|
||||
export type MapPresentationFacetValue = {
|
||||
value: string;
|
||||
label: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type MapPresentationFacet = {
|
||||
id: string;
|
||||
field: string;
|
||||
label: string;
|
||||
filterable: boolean;
|
||||
counter: boolean;
|
||||
values: MapPresentationFacetValue[];
|
||||
};
|
||||
|
||||
export type MapPresentationStyle = {
|
||||
id: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export type MapPresentationClass = {
|
||||
id: string;
|
||||
label: string;
|
||||
priority: number;
|
||||
match: Array<{ field: string; equals: string }>;
|
||||
styleId: string;
|
||||
renderable: boolean;
|
||||
};
|
||||
|
||||
export type MapPresentationProfile = {
|
||||
id: string;
|
||||
version: string;
|
||||
title: string;
|
||||
semanticTypes: string[];
|
||||
label: {
|
||||
mode: "subject_id" | "attributes" | "none";
|
||||
fields: string[];
|
||||
fontWeight: number;
|
||||
sizePx: number;
|
||||
color: string;
|
||||
outlineColor: string;
|
||||
outlineWidthPx: number;
|
||||
backgroundColor: string;
|
||||
backgroundOpacity: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
maxLength: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
hideCameraHeightMeters: number;
|
||||
};
|
||||
target: {
|
||||
variant: "elevated-spike";
|
||||
stemHeightMeters: number;
|
||||
headSizePx: number;
|
||||
stemWidthPx: number;
|
||||
outlineColor: string;
|
||||
outlineOpacity: number;
|
||||
outlineWidthPx: number;
|
||||
hideCameraHeightMeters: number;
|
||||
};
|
||||
facets: MapPresentationFacet[];
|
||||
styles: MapPresentationStyle[];
|
||||
classes: MapPresentationClass[];
|
||||
defaultClassId: string;
|
||||
sort: Array<{ field: string; order: string[] }>;
|
||||
};
|
||||
|
||||
export type MapSubjectFilterState = {
|
||||
/** False is an explicit empty map state. It must never be normalized to all. */
|
||||
visible: boolean;
|
||||
/** Missing facet = no constraint; an explicitly empty facet = match nothing. */
|
||||
facets: Record<string, string[]>;
|
||||
};
|
||||
|
||||
/** Application view state is keyed by stable binding id, never by editable labels. */
|
||||
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
|
||||
|
||||
/**
|
||||
* Application manifests persisted before profile v1.1 used the internal key
|
||||
* `pin`. Normalize that storage shape before the first React render so an old
|
||||
* application cannot crash while it is being upgraded to the public `target`
|
||||
* contract through MCP.
|
||||
*/
|
||||
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]) {
|
||||
return profiles.flatMap((profile) => {
|
||||
const legacyPin = (profile as MapPresentationProfile & { pin?: MapPresentationProfile["target"] }).pin;
|
||||
const target = profile.target ?? legacyPin;
|
||||
if (!target) return [];
|
||||
const normalized = { ...profile, target } as MapPresentationProfile & { pin?: MapPresentationProfile["target"] };
|
||||
delete normalized.pin;
|
||||
return [normalized];
|
||||
});
|
||||
}
|
||||
|
||||
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters) {
|
||||
const state = filters[bindingId];
|
||||
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one interactive facet-chip transition without collapsing the storage
|
||||
* contract. A missing field means unconstrained, while an explicitly persisted
|
||||
* empty array remains available to represent an intentional match-nothing view.
|
||||
*/
|
||||
export function toggleMapPresentationFacetSelection(
|
||||
facets: Record<string, string[]>,
|
||||
field: string,
|
||||
value: string,
|
||||
) {
|
||||
const selected = facets[field];
|
||||
if (!selected?.includes(value)) {
|
||||
return { ...facets, [field]: [...(selected ?? []), value] };
|
||||
}
|
||||
|
||||
const nextSelected = selected.filter((item) => item !== value);
|
||||
if (nextSelected.length > 0) return { ...facets, [field]: nextSelected };
|
||||
|
||||
const { [field]: _removed, ...unconstrained } = facets;
|
||||
return unconstrained;
|
||||
}
|
||||
|
||||
export function mapPresentationProfileForFact(
|
||||
profiles: MapPresentationProfile[],
|
||||
presentationProfileId: string | undefined,
|
||||
semanticType: string,
|
||||
) {
|
||||
const exact = presentationProfileId
|
||||
? profiles.find((profile) => profile.id === presentationProfileId)
|
||||
: undefined;
|
||||
if (exact?.semanticTypes.includes(semanticType)) return exact;
|
||||
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
|
||||
}
|
||||
|
||||
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile) {
|
||||
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
||||
return classes.find((item) => item.match.every((condition) => (
|
||||
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
|
||||
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
|
||||
}
|
||||
|
||||
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass) {
|
||||
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
|
||||
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
|
||||
}
|
||||
|
||||
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile) {
|
||||
if (profile?.label.mode === "subject_id") return fact.sourceId;
|
||||
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
|
||||
for (const key of fields) {
|
||||
const value = fact.attributes[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const normalized = value.trim();
|
||||
const limit = profile?.label.maxLength ?? 80;
|
||||
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}…` : normalized;
|
||||
}
|
||||
}
|
||||
return fact.sourceId;
|
||||
}
|
||||
|
||||
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile) {
|
||||
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
|
||||
}
|
||||
|
||||
export function mapRuntimeFactIsVisible(
|
||||
fact: MapRuntimeFact,
|
||||
profile: MapPresentationProfile,
|
||||
filters: MapPresentationFilters,
|
||||
bindingId: string,
|
||||
) {
|
||||
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
|
||||
}
|
||||
|
||||
export function mapFactMatchesFilters(
|
||||
fact: MapRuntimeFact,
|
||||
profile: MapPresentationProfile,
|
||||
filters: MapPresentationFilters,
|
||||
bindingId: string,
|
||||
) {
|
||||
const state = filters[bindingId];
|
||||
if (state?.visible === false) return false;
|
||||
|
||||
const selectedFacets = profile.facets.flatMap((facet) => {
|
||||
const selected = state?.facets?.[facet.field];
|
||||
if (selected === undefined) return [];
|
||||
return [{ facet, selected }];
|
||||
});
|
||||
|
||||
// Persisted empty arrays are an explicit match-nothing state. Interactive
|
||||
// deselection removes the field instead, so this branch is only reached for
|
||||
// a deliberately saved empty view.
|
||||
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
|
||||
if (selectedFacets.length === 0) return true;
|
||||
|
||||
// Chips form one global union. This mirrors the objects window: choosing
|
||||
// values from two categories expands the visible set instead of requiring a
|
||||
// fact to satisfy both categories simultaneously.
|
||||
return selectedFacets.some(({ facet, selected }) => (
|
||||
mapFactParticipatesInFacet(fact, profile, facet)
|
||||
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
|
||||
));
|
||||
}
|
||||
|
||||
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile) {
|
||||
for (const rule of profile.sort) {
|
||||
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
|
||||
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
|
||||
if (leftRank !== rightRank) return leftRank - rightRank;
|
||||
}
|
||||
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
|
||||
}
|
||||
|
||||
export function mapPresentationFacetCounts(
|
||||
facts: MapRuntimeFact[],
|
||||
profile: MapPresentationProfile,
|
||||
) {
|
||||
return Object.fromEntries(profile.facets.map((facet) => {
|
||||
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
|
||||
for (const fact of facts) {
|
||||
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
|
||||
const value = normalizedFacetValue(fact.attributes[facet.field]);
|
||||
if (Object.hasOwn(counts, value)) counts[value] += 1;
|
||||
}
|
||||
return [facet.field, counts];
|
||||
})) as Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `signal_state` and `movement_state` remain orthogonal Data Product facts.
|
||||
* The operational Map, however, must not present a stale last speed as a
|
||||
* current movement state. When both canonical facets exist, the movement
|
||||
* facet is therefore scoped to currently active subjects. Other profiles and
|
||||
* fields keep their ordinary independent-facet behaviour.
|
||||
*/
|
||||
function mapFactParticipatesInFacet(
|
||||
fact: MapRuntimeFact,
|
||||
profile: MapPresentationProfile,
|
||||
facet: MapPresentationFacet,
|
||||
) {
|
||||
if (facet.field !== "movement_state") return true;
|
||||
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
|
||||
return normalizedFacetValue(fact.attributes.signal_state) === "active";
|
||||
}
|
||||
|
||||
function normalizedFacetValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
|
||||
}
|
||||
|
||||
function sortRank(order: string[], value: string) {
|
||||
const index = order.indexOf(value);
|
||||
return index === -1 ? order.length : index;
|
||||
}
|
||||
Reference in New Issue
Block a user