288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
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;
|
|
} | {
|
|
variant: "surface-fill";
|
|
}) & {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* A missing facet is the compact canonical representation of every configured
|
|
* value being enabled. An explicit array is the exact enabled subset; an empty
|
|
* array therefore remains an intentional match-nothing state.
|
|
*/
|
|
export function mapPresentationFacetValueIsEnabled(
|
|
facets: Record<string, string[]>,
|
|
field: string,
|
|
value: string,
|
|
) {
|
|
const selected = facets[field];
|
|
return selected === undefined || selected.includes(value);
|
|
}
|
|
|
|
export function normalizeMapPresentationFacetSelections(
|
|
facets: Record<string, string[]>,
|
|
profile: MapPresentationProfile,
|
|
) {
|
|
return Object.fromEntries(profile.facets.flatMap((facet) => {
|
|
const selected = facets[facet.field];
|
|
if (selected === undefined) return [];
|
|
const availableValues = [...new Set(facet.values.map((item) => item.value))];
|
|
const enabledValues = availableValues.filter((value) => selected.includes(value));
|
|
// Legacy layouts could persist every value explicitly. Canonicalize that
|
|
// shape to an unconstrained facet so scoped facets (for example movement
|
|
// on online subjects) cannot accidentally suppress unrelated subjects.
|
|
return availableValues.length > 0 && enabledValues.length === availableValues.length
|
|
? []
|
|
: [[facet.field, enabledValues] as const];
|
|
}));
|
|
}
|
|
|
|
export function toggleMapPresentationFacetSelection(
|
|
facets: Record<string, string[]>,
|
|
field: string,
|
|
value: string,
|
|
availableValues: string[],
|
|
) {
|
|
const values = [...new Set(availableValues)];
|
|
if (!values.includes(value)) return facets;
|
|
const selected = facets[field];
|
|
const enabled = new Set(selected === undefined
|
|
? values
|
|
: values.filter((item) => selected.includes(item)));
|
|
if (enabled.has(value)) enabled.delete(value);
|
|
else enabled.add(value);
|
|
const nextEnabled = values.filter((item) => enabled.has(item));
|
|
const next = { ...facets };
|
|
if (nextEnabled.length === values.length) delete next[field];
|
|
else next[field] = nextEnabled;
|
|
return next;
|
|
}
|
|
|
|
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 and the final interactive deselection are an
|
|
// explicit match-nothing state.
|
|
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
|
|
if (selectedFacets.length === 0) return true;
|
|
|
|
// Values inside one facet form a union; independently selected facets are
|
|
// conjunctive. This keeps provider/type/state filters composable.
|
|
return selectedFacets.every(({ 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;
|
|
}
|