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; }; /** Application view state is keyed by stable binding id, never by editable labels. */ export type MapPresentationFilters = Record; /** * 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, field: string, value: string, ) { const selected = facets[field]; return selected === undefined || selected.includes(value); } export function normalizeMapPresentationFacetSelections( facets: Record, 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, 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>; } /** * `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; }