Files
NODEDC_DESIGN_GUIDELINE/apps/catalog/src/mapPresentationProfile.ts
T

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;
}