diff --git a/apps/geoportal/src/app/components/layers/InteractionView.tsx b/apps/geoportal/src/app/components/layers/InteractionView.tsx
index f7f1a63a1b..ab657e7c88 100644
--- a/apps/geoportal/src/app/components/layers/InteractionView.tsx
+++ b/apps/geoportal/src/app/components/layers/InteractionView.tsx
@@ -126,10 +126,15 @@ const InteractionView = ({ isDragging }: { isDragging?: boolean }) => {
const showFilter = hasLayerFilterControl(layer);
const groupAddon = resolveActiveTargetAddon(group, activeInteractionButtonID);
+ // the same lookup for a single layer entry: a tool declared on one layer is
+ // mounted here while its trigger is active, exactly as a group's tool is
+ const layerAddon = resolveActiveTargetAddon(layer, activeInteractionButtonID);
const content =
group && groupAddon ? (
+ ) : layer && layerAddon ? (
+
) : layer && (hasInteractionComponent || showFilter) ? (
) : null;
diff --git a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts
index c894215a7f..842a958900 100644
--- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts
+++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts
@@ -52,6 +52,41 @@ export const bodenFachzwilling: FachzwillingRoute = {
},
},
{ kind: "vectorHighlight", config: { modifierClick: "alt", lasso: true } },
+ {
+ /**
+ * Arrow-key navigation, global shape: every navigable layer on the map,
+ * toggled from the control column. The identical config object is
+ * declared on a workflow's `tools` in the Gesundheit Fachzwilling, where
+ * the same addon is scoped to that workflow's layer group instead —
+ * nothing but the place of declaration differs.
+ */
+ kind: "featureKeyboardNav",
+ config: {
+ sharpness: 0.5,
+ crossLayer: "prefer-current",
+ explain: "hold",
+ // cost multiplier for a hit on the ray of the pressed direction against
+ // the two fan rays beside it. 1 ranks all three alike and is how this
+ // behaved before the option existed; below 1 the fan has to be that
+ // much nearer to win (0.85: about 15%); 0 makes anything the centre ray
+ // touches win outright, however far away it is
+ centerRayBonus: 0.9,
+ // the mode is what this Fachzwilling is being built around right now,
+ // so it starts switched on rather than one click away
+ startActive: true,
+ // to compare by hand: "pole" is furthest from any edge, "spine" the
+ // middle along the shape, "centroid" the area centroid. The last two
+ // fall back to the pole where their point lands outside the feature
+ originStrategy: "spine",
+ // "dynamic": a feature walked into measures from where the walk
+ // arrived, drawn red. "static": always the computed origin above
+ originMode: "dynamic",
+ // blue dot on every visible shape at the point a step from it would
+ // start; makes a parcel whose interior point falls inside its building
+ // readable at a glance
+ showOrigins: true,
+ },
+ },
{
kind: "visibleFeatureStatsSource",
config: {
diff --git a/apps/geoportal/src/app/constants/fachzwillinge/gesundheit.ts b/apps/geoportal/src/app/constants/fachzwillinge/gesundheit.ts
index dead33bcd4..e3e1612413 100644
--- a/apps/geoportal/src/app/constants/fachzwillinge/gesundheit.ts
+++ b/apps/geoportal/src/app/constants/fachzwillinge/gesundheit.ts
@@ -58,7 +58,20 @@ export const gesundheitFachzwilling: FachzwillingRoute = {
thumbnail:
"https://geo.wuppertal.de/geoportal/geoportal_vorschau/infra_apotheken.png",
layers: ["wuppPOI:poi_krankenhaeuser", "wuppInfra:apotheken"],
- tools: ["layerVisibility"],
+ tools: [
+ "layerVisibility",
+ // the workflow shape of the arrow-key navigation: scoped to the two
+ // layers of this group, toggled from the group's own button. Same
+ // config object as the global declaration in the Boden Fachzwilling.
+ {
+ kind: "featureKeyboardNav",
+ config: {
+ sharpness: 0.5,
+ crossLayer: "prefer-current",
+ explain: "brief",
+ },
+ },
+ ],
metaDataText:
"Die Gruppe bündelt die Datensätze Krankenhäuser (wuppPOI) und " +
"Apotheken (wuppInfra) aus dem Geoportal Wuppertal.",
diff --git a/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts b/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts
index 8c44f6e356..395852c634 100644
--- a/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts
+++ b/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts
@@ -3,6 +3,10 @@ import { useDispatch, useSelector } from "react-redux";
import maplibregl from "maplibre-gl";
import { useMapSelection } from "@carma-mapping/contexts";
+import {
+ getCarmaConf,
+ resolvePropertyTarget,
+} from "@carma-mapping/engines/maplibre";
import { utils } from "@carma-appframeworks/portals";
import {
@@ -37,6 +41,91 @@ const MAX_SELECTION_COUNT = 10;
const RECLICK_DELAY_MS = 250;
+/** The identity a feature keeps across tiles and queries. */
+const featureKeyOf = (
+ feature:
+ | { source?: string; sourceLayer?: string; id?: string | number }
+ | null
+ | undefined
+) =>
+ feature?.id === undefined || feature?.id === null || !feature.source
+ ? undefined
+ : `${feature.source}|${feature.sourceLayer ?? ""}|${String(feature.id)}`;
+
+/**
+ * What a click adds to a hit before the infobox mapping ever sees it.
+ *
+ * `SelectionManager.enrichHits` attaches `carmaInfo` to every hit it returns,
+ * and `targetProperties` where the layer configures a property target. A
+ * feature published by an addon comes straight from `queryRenderedFeatures` and
+ * has neither, and the mapping functions read them: without `carmaInfo` the
+ * ALKIS mapping returns null, `createVectorFeature` then returns undefined, and
+ * nothing is dispatched — the feature is drawn as selected and the infobox
+ * keeps showing whatever was clicked last.
+ */
+const enrichSelectedFeature = (
+ feature: maplibregl.MapGeoJSONFeature,
+ map: maplibregl.Map | null | undefined
+): maplibregl.MapGeoJSONFeature => {
+ const carmaConf = getCarmaConf(feature);
+ const properties: Record = {
+ ...feature.properties,
+ carmaInfo: {
+ source: feature.source,
+ sourceLayer: feature.sourceLayer,
+ layerId: feature.layer?.id,
+ },
+ };
+
+ if (map && carmaConf?.propertyTarget) {
+ const targetProps = resolvePropertyTarget(
+ map,
+ feature.id,
+ carmaConf.propertyTarget
+ );
+ if (targetProps) properties.targetProperties = targetProps;
+ }
+
+ return {
+ ...feature,
+ geometry: feature.geometry,
+ properties,
+ } as maplibregl.MapGeoJSONFeature;
+};
+
+/**
+ * A point inside the feature's extent, standing in for the click position.
+ *
+ * `createVectorFeature` takes the click's lng/lat, which a selection published
+ * by an addon does not have. It is only used for the legacy GetFeatureInfo URL
+ * and the position readout, so the centre of the geometry's extent is a fair
+ * stand-in for "where the user is looking".
+ */
+const extentCentreOf = (
+ geometry: GeoJSON.Geometry | undefined
+): maplibregl.LngLat | undefined => {
+ if (!geometry || geometry.type === "GeometryCollection") return undefined;
+ let minLng = Infinity;
+ let minLat = Infinity;
+ let maxLng = -Infinity;
+ let maxLat = -Infinity;
+ const visit = (value: unknown) => {
+ if (!Array.isArray(value)) return;
+ if (typeof value[0] === "number" && typeof value[1] === "number") {
+ const [lng, lat] = value as [number, number];
+ if (lng < minLng) minLng = lng;
+ if (lat < minLat) minLat = lat;
+ if (lng > maxLng) maxLng = lng;
+ if (lat > maxLat) maxLat = lat;
+ return;
+ }
+ for (const entry of value) visit(entry);
+ };
+ visit(geometry.coordinates);
+ if (minLng === Infinity) return undefined;
+ return new maplibregl.LngLat((minLng + maxLng) / 2, (minLat + maxLat) / 2);
+};
+
type ClickPos = [number, number] | null;
type SelectionEvent = {
@@ -140,8 +229,12 @@ export const useLibreMapSelectionHandler = (
useEffect(() => removeFeatureInfoMarker, [removeFeatureInfoMarker]);
- const { selectFeature: selectMapFeature, clearSelection: clearMapSelection } =
- useMapSelection();
+ const {
+ selectFeature: selectMapFeature,
+ clearSelection: clearMapSelection,
+ rawFeature: contextRawFeature,
+ selectionVersion,
+ } = useMapSelection();
const selectedFeature = useSelector(getSelectedFeature);
useEffect(() => {
const feature = selectedFeature as {
@@ -359,6 +452,64 @@ export const useLibreMapSelectionHandler = (
[dispatch]
);
+ /**
+ * The other direction: a selection published into the map selection context
+ * becomes the app's selected feature.
+ *
+ * Everything above flows one way, from the store into the context, because
+ * clicking was the only way this app selected anything. An addon that
+ * publishes a selection — arrow-key navigation does — was therefore drawn as
+ * selected and nothing else: the infobox kept showing the feature clicked
+ * before it, and the store still held that one, so clicking it again counted
+ * as a re-click and zoomed to it.
+ *
+ * It goes through `handleSelectionChanged` rather than beside it, as the hit a
+ * click would have produced, so the infobox is built by exactly the code a
+ * click runs. The store's feature carries the `sourceFeature` it was built
+ * from, so comparing identities tells an addon's selection from the echo of
+ * this app's own and ends the round trip.
+ */
+ useEffect(() => {
+ if (!contextRawFeature) return;
+
+ const current = getSelectedFeature(store.getState()) as {
+ sourceFeature?: maplibregl.MapGeoJSONFeature;
+ } | null;
+ if (
+ featureKeyOf(contextRawFeature) === featureKeyOf(current?.sourceFeature)
+ ) {
+ return;
+ }
+
+ const map = libreMapRef.current;
+ const latlng = extentCentreOf(contextRawFeature.geometry);
+ if (!map || !latlng) return;
+
+ // `SelectionManager` enriches real hits before the app ever sees them;
+ // a feature from `queryRenderedFeatures` needs the same treatment
+ const hit = enrichSelectedFeature(contextRawFeature, map);
+ const layerId = hit.layer?.metadata?.["layer-id"];
+ const layer = getLayers(store.getState()).find(
+ (entry) => entry.id === layerId
+ );
+ console.info("[SELECTION_SYNC] building infobox feature", {
+ layerId,
+ layerFound: !!layer,
+ propertyKeys: Object.keys(hit.properties ?? {}),
+ infoboxMapping: Array.isArray(layer?.conf?.infoboxMapping)
+ ? (layer.conf.infoboxMapping as string[]).join("\n")
+ : layer?.conf?.infoboxMapping,
+ });
+ if (!layer) return;
+
+ void createVectorFeature(layer, hit, map, latlng).then((feature) => {
+ console.info("[SELECTION_SYNC] built", { hasFeature: !!feature });
+ if (feature) dispatch(setSelectedFeature(feature));
+ });
+ // `selectionVersion` stands for a reselection of the same feature object
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [contextRawFeature, selectionVersion, dispatch]);
+
// Pre-select the preferred hit (sticky layer from the infobox thumbnail
// switcher) before CarmaMap applies its default visual selection on the
// topmost hit. Without this, clicks in feature-info mode flicker: CarmaMap
diff --git a/libraries/mapping/addons/README.md b/libraries/mapping/addons/README.md
index 5e1c25d07b..a31785f66d 100644
--- a/libraries/mapping/addons/README.md
+++ b/libraries/mapping/addons/README.md
@@ -38,6 +38,7 @@ so the second folder is the list of what actually exists:
| `addons/GazetteerMode.tsx` | extra mode in the gazetteer mode dropdown |
| `addons/VectorHighlight.tsx` | highlight/dim mode for the maplibre map |
| `addons/LayerVisibility.tsx` | per-member visibility toggles for a group |
+| `addons/FeatureKeyboardNav.tsx` | arrow-key navigation over vector features (`feature-keyboard-nav/` holds its parts) |
An addon that needs more than one file gets its own folder there
(`addons/CameraTour/index.tsx` plus its parts).
@@ -84,6 +85,16 @@ two libraries circular. Declaration sites keep full kind checking by narrowing t
`WorkflowPerspective` type parameter, as the geoportal does with
`WorkflowPerspective`.
+### Layer tools
+
+A single layer entry carries `tools` the same way, from its catalog item or from
+the `carmaConf` of its vector style (`parseToMapLayer` in
+`@carma-mapping/utils`). The geoportal mounts those through the same
+`TargetAddonHost`, with the layer as the target, so one kind can be declared on a
+route, on a workflow's group and on a single layer without knowing the
+difference. `FeatureKeyboardNav` is written that way: its scope is whatever its
+declaration site gives it, and its picking core never learns which one that was.
+
## Where an addon's UI ends up
There is no `surface` field. An addon renders whatever it wants, and one optional
diff --git a/libraries/mapping/addons/project.json b/libraries/mapping/addons/project.json
index f2543d6afe..a9a23e6722 100644
--- a/libraries/mapping/addons/project.json
+++ b/libraries/mapping/addons/project.json
@@ -7,6 +7,13 @@
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
+ },
+ "test": {
+ "executor": "@nx/vite:test",
+ "outputs": ["{options.reportsDirectory}"],
+ "options": {
+ "reportsDirectory": "../../../coverage/libraries/mapping/addons"
+ }
}
}
}
diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx
new file mode 100644
index 0000000000..ee8729b467
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx
@@ -0,0 +1,1118 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { Map as MaplibreMap, MapGeoJSONFeature } from "maplibre-gl";
+
+import {
+ faArrowsUpDownLeftRight,
+ faTriangleExclamation,
+ faXmark,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Tooltip } from "antd";
+
+import { useDatasheet, useMapSelection } from "@carma-mapping/contexts";
+import {
+ Control,
+ ControlButtonStyler,
+ type Positions,
+} from "@carma-mapping/map-controls-layout";
+
+import { useAddonState } from "../lib/AddonStateContext";
+import type { AddonComponentProps, AddonTrigger } from "../lib/registry";
+
+import {
+ candidateKeyOf,
+ type CandidateSet,
+ type NavCandidate,
+} from "./feature-keyboard-nav/candidates";
+import {
+ DEFAULT_CANDIDATE_DEBOUNCE_MS,
+ DEFAULT_CROSS_LAYER,
+ DEFAULT_CURRENT_LAYER_BONUS,
+ DEFAULT_CENTER_RAY_BONUS,
+ DEFAULT_EDGE_BEHAVIOR,
+ DEFAULT_EXPLAIN,
+ DEFAULT_EXPLAIN_MS,
+ DEFAULT_FAN_DEG,
+ DEFAULT_MAX_CANDIDATES,
+ DEFAULT_MIN_STEP_PX,
+ DEFAULT_ORIGIN_DOT_OPACITY,
+ DEFAULT_PAN_DURATION_MS,
+ DEFAULT_PAN_STEP_FRACTION,
+ DEFAULT_STRATEGY,
+ DEFAULT_VERIFY_MAX_RETRIES,
+ EDGE_PAN_SETTLE_TIMEOUT_MS,
+ KEEP_IN_VIEW_INSET_FRACTION,
+ nextExplainMode,
+ resolveNavConstants,
+ SHIFT_PAN_PX,
+} from "./feature-keyboard-nav/constants";
+import {
+ ExplainLegend,
+ ExplainOverlay,
+ FeatureOriginDots,
+ toExplainSnapshot,
+ type ExplainSnapshot,
+ type OriginDot,
+} from "./feature-keyboard-nav/ExplainOverlay";
+import { chordMidpoint } from "./feature-keyboard-nav/geometry";
+import {
+ isTypingTarget,
+ navHintRows,
+ resolveNavBinding,
+ type NavKeyBinding,
+} from "./feature-keyboard-nav/keymap";
+import {
+ interiorPointOf,
+ isAreaGeometry,
+ originCandidatesOf,
+ type OriginStrategy,
+} from "./feature-keyboard-nav/origin";
+import { pickInDirection, rankedKeys } from "./feature-keyboard-nav/pick";
+import {
+ projectCandidates,
+ viewportDiagonalPx,
+} from "./feature-keyboard-nav/projection";
+import { catalogLayerIdOfFeature } from "./feature-keyboard-nav/scope";
+import { useNavCandidates } from "./feature-keyboard-nav/useNavCandidates";
+import { useNavScope } from "./feature-keyboard-nav/useNavScope";
+import {
+ NAV_AXES,
+ type FeatureKeyboardNavConfig,
+ type NavDirection,
+ type NavExplainMode,
+ type PickExplanation,
+ type ScreenPoint,
+} from "./feature-keyboard-nav/types";
+
+/**
+ * Arrow-key navigation over vector features on the MapLibre map.
+ *
+ * The arrow keys select "the feature lying in that direction on screen".
+ * Direction always means screen direction, never compass direction: with the
+ * map rotated by any bearing, `ArrowUp` selects towards the top edge, because
+ * every number is computed on projected pixels.
+ *
+ * One component and one config object serve three deployment shapes,
+ * distinguished only by where the addon is declared:
+ *
+ * - on a route's addon list: every navigable layer, toggled from the control
+ * column;
+ * - on a workflow's `tools`: the layers of the group that workflow creates,
+ * toggled from the group's own button;
+ * - on a layer entry's `tools`: that one catalog layer, from its own button.
+ *
+ * In the two tool shapes the addon is only mounted while its trigger is active,
+ * so being mounted *is* the mode. Nothing in the picking core knows which shape
+ * it is running in; the shapes only differ in what `resolveNavScope` returns.
+ *
+ * The renderer stays out of the hot path: the candidate set is queried once per
+ * settled map movement and every keypress is arithmetic on it. See
+ * `useNavCandidates` for why, and for what that costs.
+ */
+
+export type FeatureNavigationModeState = {
+ isOn: boolean;
+ /** the candidate set is bounded or a query failed; surfaced, never hidden */
+ degraded: boolean;
+};
+
+const DEFAULT_CONTROL_POSITION: Positions = "topleft";
+/** geoportal's topleft column: measurement 60, vector highlight 70, terrain 80 */
+const DEFAULT_CONTROL_ORDER = 75;
+/** nothing else uses the bottom-center column, so the order only has to exist */
+const LEGEND_CONTROL_ORDER = 10;
+const ACTIVE_COLOR = "#1677ff";
+const WARNING_COLOR = "#d4380d";
+/**
+ * One colour per way of placing the origin, so all of them can be read at once
+ * while the strategy is still being chosen by hand. A filled dot is usable, a
+ * hollow one fell outside the feature and is why the pole is used instead.
+ */
+const ORIGIN_COLORS: Readonly> = {
+ /** where a walk entered the feature */
+ arrival: "#e8323c",
+ /** furthest from any edge */
+ pole: "#1677ff",
+ /** half way along the shape */
+ spine: "#13a10e",
+ /** the area centroid */
+ centroid: "#b37feb",
+};
+const FADE_MS = 400;
+/** half-size of the box `verifyWithRenderer` asks about, in pixels */
+const VERIFY_PROBE_PX = 3;
+
+/** the direction that undoes a step */
+const OPPOSITE_DIRECTION: Readonly> = {
+ up: "down",
+ down: "up",
+ left: "right",
+ right: "left",
+};
+/** features the path remembers; older ones are dropped from the far end */
+const MAX_NAV_TRAIL = 100;
+
+/** One visited feature, with the direction that led into it. */
+type NavPathEntry = {
+ /** the feature as queried, so re-selecting it needs no new query */
+ feature: MapGeoJSONFeature;
+ /** `undefined` on the feature the walk started from */
+ direction?: NavDirection;
+};
+
+/**
+ * The walk as a path with a position on it, rather than a stack.
+ *
+ * A stack only replays backwards: stepping back pops the entry, which throws
+ * the way forward away, so up-up-up-down-down-up runs the picker again for that
+ * last up although the answer was known. With a cursor, both directions replay
+ * — back while the pressed key undoes the step that led here, forward while it
+ * repeats the step that was taken from here — and only a step that leaves the
+ * remembered path asks the picker. Stepping off the middle of the path drops
+ * what lay ahead, the way an edit after an undo does.
+ */
+type NavPath = { entries: NavPathEntry[]; cursor: number };
+
+export const featureKeyboardNavTrigger: AddonTrigger<"featureKeyboardNav"> = {
+ icon: faArrowsUpDownLeftRight,
+ label: () => "Mit Pfeiltasten durch die Objekte navigieren",
+ // the two tool shapes always have a target; the global shape never renders a
+ // trigger, since `AddonHost` mounts it directly
+ isApplicable: ({ target }) => target !== null,
+};
+
+/** The origin of a step: an interior point of the selected feature. */
+type NavOrigin = {
+ point: ScreenPoint;
+ isArea: boolean;
+ key?: string;
+ layerId?: string;
+};
+
+const resolveOrigin = (
+ map: MaplibreMap,
+ feature: MapGeoJSONFeature | null,
+ candidates: CandidateSet,
+ arrivals: Map,
+ strategy: OriginStrategy,
+ mode: "dynamic" | "static"
+): NavOrigin | undefined => {
+ if (feature) {
+ const key = candidateKeyOf(feature);
+ // where the walk actually entered this feature, when it was walked into:
+ // the middle of the stretch the step's ray spent inside it. Closer to what
+ // the user is looking at than the pole, which on a long street can sit far
+ // down the road and send the next step off from there
+ const arrival = mode === "static" || !key ? undefined : arrivals.get(key);
+ // the candidate holds every tile piece of this feature merged into one
+ // geometry; `feature.geometry` is whichever piece the query returned first,
+ // and on a feature that spans tiles its interior point is not the feature's
+ const merged = key ? candidates.byKey.get(key)?.geometry : undefined;
+ const interior =
+ arrival ?? interiorPointOf(merged ?? feature.geometry, strategy);
+ if (interior) {
+ const projected = map.project([interior[0], interior[1]]);
+ const layerId = catalogLayerIdOfFeature(feature);
+ return {
+ point: { x: projected.x, y: projected.y },
+ isArea: isAreaGeometry(feature.geometry.type),
+ ...(key ? { key } : {}),
+ ...(layerId ? { layerId } : {}),
+ };
+ }
+ }
+ // bootstrap: with nothing selected the first arrow steps in from the middle
+ // of the screen, so the dataset can be entered without a click
+ const canvas = map.getCanvas();
+ if (canvas.clientWidth === 0 || canvas.clientHeight === 0) return undefined;
+ return {
+ point: { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 },
+ isArea: false,
+ };
+};
+
+/**
+ * Confirm the winner against what the renderer actually draws, and walk down
+ * the ranking while it says no. An error is not a no: the candidate set is the
+ * authority, the check is the second opinion, so a failing query accepts the
+ * computed winner rather than dropping the step.
+ */
+const verifyWinner = (
+ map: MaplibreMap,
+ explanation: PickExplanation,
+ maxRetries: number
+): string | undefined => {
+ const order = rankedKeys(explanation);
+ const computed = order[0];
+ if (computed === undefined) return undefined;
+
+ for (
+ let attempt = 0;
+ attempt <= maxRetries && attempt < order.length;
+ attempt++
+ ) {
+ const key = order[attempt];
+ const evaluation = explanation.evaluations.find(
+ (entry) => entry.key === key
+ );
+ if (!evaluation) continue;
+ const { x, y } = evaluation.nearestPointPx;
+ try {
+ // a box rather than the bare point: the nearest point sits exactly on the
+ // outline, where a one-pixel query answers on rounding alone
+ const probe: [[number, number], [number, number]] = [
+ [x - VERIFY_PROBE_PX, y - VERIFY_PROBE_PX],
+ [x + VERIFY_PROBE_PX, y + VERIFY_PROBE_PX],
+ ];
+ const hits = map.queryRenderedFeatures(probe);
+ if (hits.some((hit) => candidateKeyOf(hit) === key)) return key;
+ } catch {
+ return computed;
+ }
+ }
+ return undefined;
+};
+
+/** Pan just far enough to bring `point` back inside the safe rectangle. */
+const keepInView = (
+ map: MaplibreMap,
+ point: ScreenPoint,
+ durationMs: number
+) => {
+ const canvas = map.getCanvas();
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ const insetX = width * KEEP_IN_VIEW_INSET_FRACTION;
+ const insetY = height * KEEP_IN_VIEW_INSET_FRACTION;
+
+ let dx = 0;
+ let dy = 0;
+ if (point.x < insetX) dx = point.x - insetX;
+ else if (point.x > width - insetX) dx = point.x - (width - insetX);
+ if (point.y < insetY) dy = point.y - insetY;
+ else if (point.y > height - insetY) dy = point.y - (height - insetY);
+
+ // zoom is never changed by navigation
+ if (dx !== 0 || dy !== 0) map.panBy([dx, dy], { duration: durationMs });
+};
+
+/**
+ * Presentational, like `VectorHighlight`'s: `Control` re-registers its children
+ * on every render, so state kept here would be dropped.
+ */
+const NavModeButton = ({
+ isOn,
+ degraded,
+ onClick,
+}: {
+ isOn: boolean;
+ degraded: boolean;
+ onClick: () => void;
+}) => (
+
+
+
+
+
+);
+
+/** The keymap, rendered from the same table the key handler matches against. */
+const NavHintChip = ({
+ degraded,
+ hasActivateHandler,
+}: {
+ degraded: boolean;
+ hasActivateHandler: boolean;
+}) => (
+
+);
+
+export const FeatureKeyboardNav = ({
+ config = {},
+ libreMap,
+ target,
+}: AddonComponentProps<"featureKeyboardNav">) => {
+ const {
+ strategy = DEFAULT_STRATEGY,
+ fanDeg = DEFAULT_FAN_DEG,
+ minStepPx = DEFAULT_MIN_STEP_PX,
+ crossLayer = DEFAULT_CROSS_LAYER,
+ currentLayerBonus = DEFAULT_CURRENT_LAYER_BONUS,
+ centerRayBonus = DEFAULT_CENTER_RAY_BONUS,
+ verifyWithRenderer = false,
+ verifyMaxRetries = DEFAULT_VERIFY_MAX_RETRIES,
+ edgeBehavior = DEFAULT_EDGE_BEHAVIOR,
+ panStepFraction = DEFAULT_PAN_STEP_FRACTION,
+ panDurationMs = DEFAULT_PAN_DURATION_MS,
+ explain = DEFAULT_EXPLAIN,
+ explainMs = DEFAULT_EXPLAIN_MS,
+ showOrigins = false,
+ originDotOpacity = DEFAULT_ORIGIN_DOT_OPACITY,
+ originStrategy = "pole",
+ originMode = "dynamic",
+ autoActivateOnSelect = false,
+ startActive = false,
+ showControl = true,
+ controlPosition = DEFAULT_CONTROL_POSITION,
+ controlOrder = DEFAULT_CONTROL_ORDER,
+ maxCandidates = DEFAULT_MAX_CANDIDATES,
+ candidateDebounceMs = DEFAULT_CANDIDATE_DEBOUNCE_MS,
+ } = config;
+
+ const isToolShape = target !== null;
+ const [mode, setMode] = useAddonState("featureNavigationMode");
+ /**
+ * Escape in a tool shape. The trigger that mounted the addon belongs to the
+ * app's interaction state, which a library must not write, so leaving the
+ * mode suspends navigation until the button is toggled off and on again.
+ */
+ const [suspended, setSuspended] = useState(false);
+ /**
+ * `startActive` is what the channel falls back to, not something written into
+ * it on mount. The addon state provider drops every channel when its scope
+ * identity changes, and a route rebuilding its `addons` array on a render is
+ * exactly that, so a value written once is lost again. Read as a fallback the
+ * mode is on from the first render and survives every reset, while switching
+ * off by hand stores `isOn: false`, a value, which wins over the fallback.
+ */
+ const isActive = isToolShape ? !suspended : mode?.isOn ?? startActive;
+
+ const { selectFeature, clearSelection, rawFeature, selectionVersion } =
+ useMapSelection();
+ const datasheet = useDatasheet();
+ // "registered only when the host supplies one": without a DatasheetProvider
+ // there is nothing to activate, and Enter stays unbound
+ const hasActivateHandler = datasheet.isEnabled;
+
+ // key on the content: route configs pass a fresh array per render
+ const layerPatternKey = (config.layers ?? []).join("|");
+ const layerPatterns = useMemo(
+ () => (layerPatternKey ? layerPatternKey.split("|") : []),
+ [layerPatternKey]
+ );
+ const scope = useNavScope(libreMap, target, layerPatterns);
+
+ const { candidateSet, version } = useNavCandidates({
+ map: libreMap,
+ scope,
+ enabled: isActive,
+ maxCandidates,
+ debounceMs: candidateDebounceMs,
+ });
+
+ const candidateSetRef = useRef(candidateSet);
+ candidateSetRef.current = candidateSet;
+
+ /** The feature steps are measured from, kept in a ref so a fast repeat of a
+ * key does not measure from the selection two steps ago. */
+ const originFeatureRef = useRef(null);
+ useEffect(() => {
+ originFeatureRef.current = rawFeature;
+ }, [rawFeature, selectionVersion]);
+
+ const [snapshot, setSnapshot] = useState(null);
+ const [faded, setFaded] = useState(false);
+ const explainIdRef = useRef(0);
+
+ /**
+ * The picture mode as it stands.
+ *
+ * `config.explain` seeds it and decides whether the readout exists at all;
+ * from there the switch on the readout owns it, so the picture can be dropped
+ * while walking a dataset and brought back to explain a single step, without
+ * a config change and without leaving the mode. A config that changed under
+ * us wins again, since it is the deployment talking.
+ */
+ const [explainMode, setExplainMode] = useState(explain);
+ useEffect(() => {
+ setExplainMode(explain);
+ }, [explain]);
+ const cycleExplain = useCallback(() => {
+ setExplainMode(nextExplainMode);
+ }, []);
+
+ // switching the picture off takes the one on screen with it, held or not
+ useEffect(() => {
+ if (explainMode !== "off") return;
+ setSnapshot(null);
+ setFaded(false);
+ }, [explainMode]);
+
+ /**
+ * The key the last step published, so the selection it causes can be told
+ * apart from one the user made.
+ *
+ * Held until a different feature is selected rather than consumed on arrival.
+ * A host may republish the addon's own selection: the geoportal builds its
+ * infobox feature from what the addon selected and pushes that back into the
+ * selection context, so the same key arrives a second time. Consuming the
+ * claim made that echo look hand-made, which erased the picture a keypress
+ * had just drawn and dropped the walk with it. The price is that re-selecting
+ * the already selected feature by hand no longer clears anything, which is
+ * fine: the picture still describes the selection on screen.
+ */
+ const navSelectedKeyRef = useRef(undefined);
+
+ /**
+ * The features walked so far, and where on that path the walk stands.
+ *
+ * Up and then down has to land on the feature it started from, and picking
+ * cannot guarantee that: the reverse step measures from a different origin
+ * through a different cone, so a neighbour that came second on the way out
+ * can win on the way back. Replaying the path makes the return exact rather
+ * than merely likely, and it is also the cheaper path, since it projects no
+ * candidates at all.
+ */
+ const pathRef = useRef({ entries: [], cursor: -1 });
+
+ /**
+ * Where a step entered each feature it walked into, in lng/lat.
+ *
+ * Kept per feature rather than for the selection alone, so walking back along
+ * the trail returns to the same origin the walk had there. Cleared whenever
+ * the walk itself is dropped: a hand-made selection or leaving the mode.
+ */
+ const arrivalsRef = useRef(new Map());
+ /** the key whose arrival origin is currently drawn, for the dot's colour */
+ const [arrivalKey, setArrivalKey] = useState(undefined);
+
+ /**
+ * A selection the addon did not make erases the picture at once.
+ *
+ * The explanation describes one step: which candidates were considered, from
+ * where, and why one of them won. The moment the user picks a feature by
+ * hand, it describes a decision that no longer led to what is selected, and a
+ * held picture (`explain: "hold"`) would otherwise stay on screen indefinitely
+ * next to the wrong feature.
+ *
+ * The trail goes with it: the walk it recorded started somewhere the user has
+ * now left, so stepping back into it would jump across the map.
+ */
+ useEffect(() => {
+ const selectedKey = rawFeature ? candidateKeyOf(rawFeature) : undefined;
+ if (selectedKey !== undefined && selectedKey === navSelectedKeyRef.current) {
+ return;
+ }
+ navSelectedKeyRef.current = undefined;
+ pathRef.current = { entries: [], cursor: -1 };
+ arrivalsRef.current.clear();
+ setArrivalKey(undefined);
+ setSnapshot(null);
+ setFaded(false);
+ }, [rawFeature, selectionVersion]);
+
+ const publishExplanation = useCallback(
+ (map: MaplibreMap, explanation: PickExplanation) => {
+ if (explainMode === "off") return;
+ explainIdRef.current += 1;
+ setFaded(false);
+ setSnapshot(toExplainSnapshot(map, explanation, explainIdRef.current));
+ },
+ [explainMode]
+ );
+
+ /**
+ * The interior point drawn while the mode runs: the one of the selected
+ * feature, whether the selection came from a click or from an arrow key.
+ *
+ * One dot, not one per visible shape. The dots are re-projected on every map
+ * frame, and a viewport of ALKIS parcels holds thousands of them, so drawing
+ * the whole candidate set stalled the main thread while panning. The point
+ * that explains a step is the origin the step measured from; the others were
+ * never the question.
+ *
+ * Not part of the keypress snapshot: it describes the selection as it stands,
+ * so it survives every pan rather than fading with the last arrow.
+ */
+ const featureOrigins = useMemo(
+ () => {
+ if (!showOrigins || explainMode === "off" || !rawFeature) return [];
+ // the merged geometry, for the same reason `resolveOrigin` uses it: the
+ // dot has to mark the point a step actually measures from
+ const key = candidateKeyOf(rawFeature);
+ const arrival =
+ originMode === "static" || !key
+ ? undefined
+ : arrivalsRef.current.get(key);
+ const merged = key ? candidateSet.byKey.get(key)?.geometry : undefined;
+ const geometry = merged ?? rawFeature.geometry;
+
+ // every strategy at once, so they can be compared on real features while
+ // the choice is still being made. The one in force is drawn larger
+ const candidates = originCandidatesOf(geometry);
+ const dots: OriginDot[] = (
+ ["pole", "spine", "centroid"] as const
+ ).flatMap((name) => {
+ const candidate = candidates[name];
+ if (!candidate) return [];
+ // a strategy that fell outside is drawn hollow and never counts as the
+ // one in force, since the origin then falls back to the pole
+ const usable = candidate.inside;
+ const inForce =
+ !arrival &&
+ (originStrategy === name ||
+ (name === "pole" && !candidates[originStrategy]?.inside));
+ return [
+ {
+ lngLat: candidate.point,
+ color: ORIGIN_COLORS[name],
+ filled: usable,
+ active: inForce,
+ },
+ ];
+ });
+
+ if (arrival) {
+ dots.push({
+ lngLat: arrival,
+ color: ORIGIN_COLORS.arrival,
+ filled: true,
+ active: true,
+ });
+ }
+
+ if (dots.length > 0) return dots;
+
+ // points and lines have no strategy to compare
+ const origin = interiorPointOf(geometry, originStrategy);
+ return origin
+ ? [{ lngLat: origin, color: ORIGIN_COLORS.pole, active: true }]
+ : [];
+ },
+ // `selectionVersion` stands for a reselection of the same feature object,
+ // `arrivalKey` for a fresh arrival point written into the ref
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ showOrigins,
+ explainMode,
+ rawFeature,
+ selectionVersion,
+ candidateSet,
+ arrivalKey,
+ originStrategy,
+ originMode,
+ ]
+ );
+
+
+ useEffect(() => {
+ if (explainMode !== "brief" || !snapshot) return;
+ const fade = setTimeout(() => setFaded(true), explainMs);
+ const clear = setTimeout(() => setSnapshot(null), explainMs + FADE_MS);
+ return () => {
+ clearTimeout(fade);
+ clearTimeout(clear);
+ };
+ // a new decision replaces the picture and restarts both timers
+ }, [snapshot, explainMode, explainMs]);
+
+ useEffect(() => {
+ if (isActive) return;
+ pathRef.current = { entries: [], cursor: -1 };
+ arrivalsRef.current.clear();
+ setArrivalKey(undefined);
+ setSnapshot(null);
+ }, [isActive]);
+
+ /** Resolved by the next settled candidate query, so an edge pan can be
+ * waited out before the retry looks again. */
+ const settleRef = useRef<(() => void) | null>(null);
+ useEffect(() => {
+ settleRef.current?.();
+ }, [version]);
+
+ const waitForCandidates = useCallback(
+ () =>
+ new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ settleRef.current = null;
+ resolve();
+ }, EDGE_PAN_SETTLE_TIMEOUT_MS);
+ settleRef.current = () => {
+ clearTimeout(timeout);
+ settleRef.current = null;
+ resolve();
+ };
+ }),
+ []
+ );
+
+ /**
+ * One direction keypress, from the origin to a published selection.
+ *
+ * The loop is the edge behaviour of 6.6: nothing in that direction eases the
+ * viewport along the axis, waits for the candidate set to settle, and looks
+ * once more. One retry, then it gives up.
+ */
+ const step = useCallback(
+ async (direction: NavDirection): Promise => {
+ const map = libreMap;
+ if (!map) return;
+
+ /**
+ * Replaying the path, in either direction.
+ *
+ * Backwards while the pressed key undoes the step that led to where the
+ * walk stands, forwards while it repeats the step that was taken from
+ * there. Either way the feature is already known, so nothing is projected
+ * and nothing is picked, and the walk returns exactly where it was.
+ */
+ const path = pathRef.current;
+ const here = path.entries[path.cursor];
+ const ahead = path.entries[path.cursor + 1];
+ const goesBack =
+ path.cursor > 0 &&
+ here?.direction !== undefined &&
+ direction === OPPOSITE_DIRECTION[here.direction];
+ const goesForward = !goesBack && ahead?.direction === direction;
+
+ if (goesBack || goesForward) {
+ path.cursor += goesBack ? -1 : 1;
+ const remembered = path.entries[path.cursor].feature;
+ originFeatureRef.current = remembered;
+ navSelectedKeyRef.current = candidateKeyOf(remembered);
+ // replaying is not a decision, so it leaves no picture behind
+ setSnapshot(null);
+ selectFeature(
+ {
+ source: remembered.source,
+ sourceLayer: remembered.sourceLayer,
+ id: remembered.id,
+ },
+ remembered
+ );
+ const restored = resolveOrigin(
+ map,
+ remembered,
+ candidateSetRef.current,
+ arrivalsRef.current,
+ originStrategy,
+ originMode
+ );
+ if (restored) keepInView(map, restored.point, panDurationMs);
+ return;
+ }
+
+ const axis = NAV_AXES[direction];
+ const constants = resolveNavConstants(config);
+
+ /**
+ * The feature the walk stood on one step ago, while the same direction is
+ * pressed again.
+ *
+ * Two parcels sharing a long diagonal border each lie in the pressed
+ * direction from the other: the ray leaving A crosses into B, and the ray
+ * leaving B crosses back into A a little further along the same seam.
+ * Both answers are right for a single keypress, so nothing about the
+ * geometry breaks the tie and the walk bounces between the pair forever.
+ * One remembered step is enough to stop it, and remembering only one is
+ * what keeps a feature the walk legitimately comes back to reachable: a
+ * ring-shaped parcel wrapping around three others is re-entered on its far
+ * side after those three, where it is no longer the step before.
+ */
+ const cameFrom =
+ here?.direction === direction
+ ? path.entries[path.cursor - 1]?.feature
+ : undefined;
+ const blocked = cameFrom
+ ? new Set([candidateKeyOf(cameFrom)])
+ : undefined;
+
+ /**
+ * What each pass of this keypress looks at.
+ *
+ * The exclusion first, then the edge pan under the same rule, and only
+ * then the same view without it. A genuine dead end, where the feature
+ * behind is all there is, still selects rather than swallowing the key.
+ */
+ const passes: Array<{
+ exclude?: ReadonlySet;
+ panFirst: boolean;
+ }> = [{ exclude: blocked, panFirst: false }];
+ if (edgeBehavior === "pan") {
+ passes.push({ exclude: blocked, panFirst: true });
+ }
+ if (blocked) passes.push({ panFirst: false });
+
+ for (const pass of passes) {
+ if (pass.panFirst) {
+ const canvas = map.getCanvas();
+ map.panBy(
+ [
+ axis.x * canvas.clientWidth * panStepFraction,
+ axis.y * canvas.clientHeight * panStepFraction,
+ ],
+ { duration: panDurationMs }
+ );
+ await waitForCandidates();
+ }
+ const candidateSetNow = candidateSetRef.current;
+ const origin = resolveOrigin(
+ map,
+ originFeatureRef.current,
+ candidateSetNow,
+ arrivalsRef.current,
+ originStrategy,
+ originMode
+ );
+ if (!origin) return;
+
+ const { candidates, byKey, degraded } = candidateSetNow;
+
+ const projected = projectCandidates({
+ map,
+ candidates,
+ origin: origin.point,
+ axis,
+ coneAngleDeg: constants.coneAngleDeg,
+ excludeKey: origin.key,
+ excludeKeys: pass.exclude,
+ });
+
+ const { explanation } = pickInDirection({
+ origin: origin.point,
+ axis,
+ candidates: projected,
+ constants,
+ originIsArea: origin.isArea,
+ currentLayerId: origin.layerId,
+ strategy,
+ crossLayer,
+ currentLayerBonus,
+ centerRayBonus,
+ minStepPx,
+ fanDeg,
+ rayLengthPx: viewportDiagonalPx(map),
+ });
+
+ const winnerKey = verifyWithRenderer
+ ? verifyWinner(map, explanation, verifyMaxRetries)
+ : explanation.winnerKey;
+
+ // the picture shows the selection that was actually made
+ publishExplanation(map, { ...explanation, winnerKey });
+
+ const winner: NavCandidate | undefined =
+ winnerKey === undefined ? undefined : byKey.get(winnerKey);
+
+ if (winner) {
+ // half the stretch the axis ray spends inside the feature it just
+ // entered, which is where the walk arrived rather than where the
+ // feature happens to be widest
+ const winnerOutline =
+ originMode === "static"
+ ? undefined
+ : projected.find((entry) => entry.key === winnerKey);
+ const arrival = winnerOutline
+ ? chordMidpoint(
+ origin.point,
+ axis,
+ winnerOutline,
+ viewportDiagonalPx(map)
+ )
+ : undefined;
+ if (arrival && winnerKey) {
+ const lngLat = map.unproject([arrival.x, arrival.y]);
+ arrivalsRef.current.set(winnerKey, [lngLat.lng, lngLat.lat]);
+ setArrivalKey(winnerKey);
+ } else if (winnerKey) {
+ arrivalsRef.current.delete(winnerKey);
+ setArrivalKey(undefined);
+ }
+
+ const cameFrom = originFeatureRef.current;
+ // the walk started somewhere: seed the path with that feature so the
+ // first step can be undone. A bootstrap step has none, since it
+ // started from the middle of the screen
+ if (path.entries.length === 0 && cameFrom) {
+ path.entries.push({ feature: cameFrom });
+ path.cursor = 0;
+ }
+ // a step off the middle of the path drops what lay ahead of it, the
+ // way an edit after an undo does
+ path.entries.length = path.cursor + 1;
+ path.entries.push({ feature: winner.feature, direction });
+ if (path.entries.length > MAX_NAV_TRAIL) path.entries.shift();
+ path.cursor = path.entries.length - 1;
+
+ originFeatureRef.current = winner.feature;
+ // claims the selection about to arrive, so the picture just published
+ // survives it while a hand-made selection still wipes it
+ navSelectedKeyRef.current = winnerKey;
+ // through the application's normal selection path; navigation never
+ // writes selection styling itself
+ selectFeature(
+ {
+ source: winner.source,
+ sourceLayer: winner.sourceLayer,
+ id: winner.feature.id,
+ },
+ winner.feature
+ );
+
+ const evaluation = explanation.evaluations.find(
+ (entry) => entry.key === winnerKey
+ );
+ if (evaluation) {
+ keepInView(map, evaluation.nearestPointPx, panDurationMs);
+ }
+
+ if (degraded) {
+ console.info(
+ "[FEATURE_KEYBOARD_NAV] navigating a truncated candidate set",
+ { candidates: candidates.length, maxCandidates }
+ );
+ }
+ return;
+ }
+ }
+ },
+ [
+ libreMap,
+ config,
+ strategy,
+ originStrategy,
+ originMode,
+ crossLayer,
+ currentLayerBonus,
+ centerRayBonus,
+ minStepPx,
+ fanDeg,
+ verifyWithRenderer,
+ verifyMaxRetries,
+ edgeBehavior,
+ panStepFraction,
+ panDurationMs,
+ maxCandidates,
+ publishExplanation,
+ selectFeature,
+ waitForCandidates,
+ ]
+ );
+
+ const endMode = useCallback(() => {
+ clearSelection();
+ originFeatureRef.current = null;
+ setSnapshot(null);
+ if (isToolShape) setSuspended(true);
+ else setMode({ isOn: false, degraded: false });
+ }, [clearSelection, isToolShape, setMode]);
+
+ /** A step is running; further step keys are dropped until it finishes. */
+ const steppingRef = useRef(false);
+
+ /** The action table, behind a ref so the key listener binds once per mode. */
+ const runActionRef = useRef<(binding: NavKeyBinding) => void>(
+ () => undefined
+ );
+ runActionRef.current = (binding) => {
+ const map = libreMap;
+ switch (binding.action) {
+ case "step":
+ if (binding.direction) {
+ // One step at a time. A held arrow repeats at the OS rate, and a step
+ // spans an awaited edge pan, so without this every repeat started
+ // another one: the steps overlapped, each measuring from a selection
+ // its predecessor had already replaced, and the selection ran behind
+ // the key by as much as a second. Dropped rather than queued, since a
+ // queue would keep moving after the key is released.
+ if (steppingRef.current) return;
+ steppingRef.current = true;
+ // a step spans an awaited edge pan, so it is a promise; a failing key
+ // must stay a failing key and not an unhandled rejection
+ step(binding.direction)
+ .catch((error: unknown) => {
+ console.warn("[FEATURE_KEYBOARD_NAV] step failed", error);
+ })
+ .finally(() => {
+ steppingRef.current = false;
+ });
+ }
+ return;
+ case "pan": {
+ if (!map || !binding.direction) return;
+ const axis = NAV_AXES[binding.direction];
+ map.panBy([axis.x * SHIFT_PAN_PX, axis.y * SHIFT_PAN_PX], {
+ duration: panDurationMs,
+ });
+ return;
+ }
+ case "exit":
+ endMode();
+ return;
+ case "activate":
+ datasheet.openDatasheet();
+ return;
+ }
+ };
+
+ useEffect(() => {
+ if (!isActive) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (isTypingTarget(event.target)) return;
+ const binding = resolveNavBinding(event, { hasActivateHandler });
+ if (!binding) return;
+ // the map's own handler is off, so nothing else would scroll the page
+ event.preventDefault();
+ runActionRef.current(binding);
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [isActive, hasActivateHandler]);
+
+ /**
+ * The map must not pan on the same arrow that steps. Its handler is disabled
+ * for as long as the mode runs and restored on exit, so leaving navigation
+ * never costs the user arrow-key panning permanently.
+ */
+ useEffect(() => {
+ if (!libreMap || !isActive) return;
+ const wasEnabled = libreMap.keyboard.isEnabled();
+ libreMap.keyboard.disable();
+ return () => {
+ if (wasEnabled) libreMap.keyboard.enable();
+ };
+ }, [libreMap, isActive]);
+
+ // opt-in: selecting a feature by click or from a list enters the mode
+ useEffect(() => {
+ if (!autoActivateOnSelect || isToolShape || !rawFeature) return;
+ setMode((previous) =>
+ previous?.isOn ? previous : { isOn: true, degraded: false }
+ );
+ }, [autoActivateOnSelect, isToolShape, rawFeature, setMode]);
+
+ const degraded = isActive && candidateSet.degraded;
+
+ // published so the host and other addons can see the mode and its health
+ useEffect(() => {
+ setMode((previous) =>
+ previous?.isOn === isActive && previous.degraded === degraded
+ ? previous
+ : { isOn: isActive, degraded }
+ );
+ }, [isActive, degraded, setMode]);
+
+ // route switch or trigger off: leave the map as it was found
+ useEffect(
+ () => () => {
+ setMode({ isOn: false, degraded: false });
+ },
+ [setMode]
+ );
+
+ // the drawing follows the geometry, the readout is a caption and belongs with
+ // the rest of the map chrome: bottom-center, clear of the gazetteer search box
+ const overlay = (
+ <>
+ {/* on while the mode is, independent of any keypress */}
+ {isActive && (
+
+ )}
+
+ {/* the readout belongs to the mode, not to a keypress: on for as long as
+ the mode runs, and carrying the switch that turns the picture itself
+ off. `config.explain: "off"` keeps both away, so a deployment that
+ wants no debug chrome gets none */}
+ {isActive && explain !== "off" && (
+
+
+
+ )}
+ >
+ );
+
+ if (!libreMap) return null;
+
+ if (isToolShape) {
+ if (!isActive) return null;
+ return (
+ <>
+
+ {overlay}
+ >
+ );
+ }
+
+ if (!showControl) return overlay;
+
+ return (
+ <>
+
+ setMode({ isOn: true, degraded: false })
+ }
+ />
+
+ {overlay}
+ >
+ );
+};
+
+export type { FeatureKeyboardNavConfig };
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx
new file mode 100644
index 0000000000..5056ecec86
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx
@@ -0,0 +1,481 @@
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import type { Map as MaplibreMap } from "maplibre-gl";
+
+import {
+ DEFAULT_ORIGIN_DOT_OPACITY,
+} from "./constants";
+import { rotate } from "./geometry";
+import type {
+ NavExplainMode,
+ NavStrategy,
+ PickExplanation,
+ ResolvedNavConstants,
+ ScreenPoint,
+} from "./types";
+
+/**
+ * The helper geometry behind one decision, drawn on the map for about a second.
+ *
+ * It renders the values carried in `PickExplanation` and never recomputes any
+ * of them, so what is shown is what the decision used and the two cannot drift
+ * apart. The effect of `sharpness` becomes visible rather than implied: the
+ * resolved constants are drawn with the picture.
+ *
+ * Nothing is added to the map style — no source, no layer — so selection,
+ * printing and style diffing are untouched. The drawing is an SVG in the map
+ * container that never takes pointer events, and it follows the map: the points
+ * are kept in lng/lat and re-projected on every `move`, including during the
+ * keep-in-view ease.
+ */
+
+/** how many candidates are drawn at all; the decision still used every one */
+const MAX_DRAWN = 24;
+/** how many of those carry their numbers as text */
+const MAX_LABELLED = 8;
+const AXIS_LENGTH_PX = 90;
+const FADE_MS = 400;
+/** the origin dot, drawn at the same size in the keypress picture and as the
+ * standing mark on the selected feature: they are the same kind of point */
+const ORIGIN_DOT_RADIUS = 5;
+
+export type ExplainSnapshot = {
+ /** bumped per keypress; restarts the fade and replaces a held picture */
+ id: number;
+ explanation: PickExplanation;
+ /** the drawn points in lng/lat, so the picture stays on the map */
+ anchors: {
+ origin: [number, number];
+ /** one per evaluation, in the order of `explanation.evaluations` */
+ evaluations: Array<[number, number] | undefined>;
+ /** one per ray, in the order of `explanation.rays` */
+ rays: Array<[number, number] | undefined>;
+ };
+};
+
+const toLngLat = (
+ map: MaplibreMap,
+ point: ScreenPoint
+): [number, number] | undefined => {
+ try {
+ const lngLat = map.unproject([point.x, point.y]);
+ return [lngLat.lng, lngLat.lat];
+ } catch {
+ return undefined;
+ }
+};
+
+/** Freeze one decision into something the overlay can keep re-projecting. */
+export const toExplainSnapshot = (
+ map: MaplibreMap,
+ explanation: PickExplanation,
+ id: number
+): ExplainSnapshot => ({
+ id,
+ explanation,
+ anchors: {
+ origin: toLngLat(map, explanation.originPx) ?? [0, 0],
+ evaluations: explanation.evaluations.map((evaluation) =>
+ toLngLat(map, evaluation.nearestPointPx)
+ ),
+ rays: (explanation.rays ?? []).map((ray) =>
+ ray.crossingPx ? toLngLat(map, ray.crossingPx) : undefined
+ ),
+ },
+});
+
+/** Re-renders the overlay while the map moves, without touching the snapshot. */
+const useMapFrame = (map: MaplibreMap | null) => {
+ const [, setFrame] = useState(0);
+ useEffect(() => {
+ if (!map) return;
+ const bump = () => setFrame((value) => value + 1);
+ map.on("move", bump);
+ map.on("resize", bump);
+ return () => {
+ map.off("move", bump);
+ map.off("resize", bump);
+ };
+ }, [map]);
+};
+
+const COLORS = {
+ origin: "#1677ff",
+ axis: "#1677ff",
+ cone: "#1677ff",
+ winner: "#0f9d58",
+ candidate: "#8c8c8c",
+ rejected: "#d4380d",
+ ray: "#722ed1",
+};
+
+const format = (value: number) =>
+ value >= 100 ? value.toFixed(0) : value.toFixed(1);
+
+/** what each picture mode is called on the switch */
+const EXPLAIN_LABELS: Readonly> = {
+ brief: "kurz",
+ hold: "halten",
+ off: "aus",
+};
+
+export const ExplainOverlay = ({
+ map,
+ snapshot,
+ faded,
+}: {
+ map: MaplibreMap | null;
+ snapshot: ExplainSnapshot | null;
+ faded: boolean;
+}) => {
+ useMapFrame(map);
+
+ if (!map || !snapshot) return null;
+
+ const container = map.getContainer();
+ const { explanation, anchors } = snapshot;
+
+ const project = (lngLat: [number, number] | undefined) => {
+ if (!lngLat) return undefined;
+ const point = map.project(lngLat);
+ return { x: point.x, y: point.y };
+ };
+
+ const origin = project(anchors.origin);
+ if (!origin) return null;
+
+ const { axis } = explanation;
+ const axisEnd = {
+ x: origin.x + axis.x * AXIS_LENGTH_PX,
+ y: origin.y + axis.y * AXIS_LENGTH_PX,
+ };
+
+ // the cone is a direction, not a place: it is drawn from the live origin at
+ // the resolved half angle rather than stored as two more anchors
+ const coneReach = Math.max(
+ AXIS_LENGTH_PX,
+ ...explanation.evaluations
+ .filter((evaluation) => evaluation.rejectedBecause === undefined)
+ .map((evaluation) => evaluation.distancePx * 1.15)
+ );
+ const coneEdge = (sign: number) => {
+ const direction = rotate(axis, sign * explanation.coneAngleDeg);
+ return {
+ x: origin.x + direction.x * coneReach,
+ y: origin.y + direction.y * coneReach,
+ };
+ };
+
+ // drawing limit only: the decision ranked every evaluation, the picture shows
+ // the ones near enough to be readable
+ const drawn = explanation.evaluations
+ .map((evaluation, index) => ({ evaluation, index }))
+ .sort((a, b) => a.evaluation.distancePx - b.evaluation.distancePx)
+ .slice(0, MAX_DRAWN);
+
+ const isWinner = (key: string) => key === explanation.winnerKey;
+
+ return createPortal(
+
+
+
,
+ container
+ );
+};
+
+/**
+ * The interior point of the selected feature, as a blue dot.
+ *
+ * Its own overlay, not part of the per-keypress picture: it answers "where does
+ * a step from here start?", which is a property of the selection rather than of
+ * one decision. It therefore appears with the selection, whether that came from
+ * a click or from an arrow key, and stays while the user pans.
+ *
+ * One dot, not one per visible shape. The layer is re-projected on every map
+ * frame, and a viewport of ALKIS parcels holds thousands of them, so drawing an
+ * interior point per candidate stalled the main thread while panning.
+ *
+ * Same size and same blue as the origin dot in the keypress picture, because it
+ * is the same point. Colour and opacity stay config, since what reads as clear
+ * depends on the basemap under it; opacity sits on the layer rather than on the
+ * circle so both paths look identical.
+ */
+/**
+ * One drawn origin.
+ *
+ * `filled` separates a point that may be used from one that fell outside the
+ * feature: the strategies other than the pole have no containment guarantee,
+ * and a hollow dot is where one of them went wrong.
+ */
+export type OriginDot = {
+ lngLat: [number, number];
+ color: string;
+ /** the point the navigation actually measures from, drawn larger */
+ active?: boolean;
+ /** inside the feature, so usable; hollow when not */
+ filled?: boolean;
+};
+
+export const FeatureOriginDots = ({
+ map,
+ origins,
+ opacity = DEFAULT_ORIGIN_DOT_OPACITY,
+}: {
+ map: MaplibreMap | null;
+ origins: OriginDot[];
+ opacity?: number;
+}) => {
+ useMapFrame(map);
+
+ if (!map || origins.length === 0) return null;
+
+ return createPortal(
+ ,
+ map.getContainer()
+ );
+};
+
+/**
+ * The constants that were in force, as a readout.
+ *
+ * Separate from the drawing because it belongs somewhere else on screen: the
+ * picture is anchored to the geometry, this is a caption. The host puts it in
+ * the control layout, where it lines up with the other map chrome instead of
+ * landing on top of the gazetteer search box in the bottom-left corner.
+ */
+export const ExplainLegend = ({
+ snapshot,
+ faded,
+ degraded = false,
+ strategy,
+ constants,
+ candidateCount,
+ explainMode,
+ onCycleExplain,
+}: {
+ snapshot: ExplainSnapshot | null;
+ faded: boolean;
+ degraded?: boolean;
+ /** the configured strategy, shown while no decision is on screen */
+ strategy: NavStrategy;
+ /** the constants in force, likewise */
+ constants: ResolvedNavConstants;
+ /** navigable features in the viewport, likewise */
+ candidateCount: number;
+ /** the picture mode as it stands, which the switch steps through */
+ explainMode: NavExplainMode;
+ onCycleExplain: () => void;
+}) => {
+ // a decision replaces the standing numbers with the ones it actually used,
+ // and hands them back when it fades: the readout describes the mode either
+ // way, so it never leaves the screen while the mode runs
+ const explanation = snapshot && !faded ? snapshot.explanation : undefined;
+
+ return (
+
+ {explanation?.strategyUsed ?? strategy} · θmax{" "}
+ {format(explanation?.coneAngleDeg ?? constants.coneAngleDeg)}° · w{" "}
+ {format(explanation?.angleWeight ?? constants.angleWeight)} · p{" "}
+ {format(explanation?.anglePower ?? constants.anglePower)} ·{" "}
+ {explanation
+ ? `${explanation.evaluations.length} Kandidaten`
+ : `${candidateCount} navigierbar`}
+ {degraded && (
+
+ {" "}
+ · Kandidatenmenge unvollständig
+
+ )}
+ {/* the one thing on the readout that takes a click, so the caption stays
+ out of the way of the map everywhere else */}
+
+ Bild: {EXPLAIN_LABELS[explainMode]}
+
+
+ );
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts
new file mode 100644
index 0000000000..811953aa89
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts
@@ -0,0 +1,233 @@
+import type { MapGeoJSONFeature } from "maplibre-gl";
+import type { Geometry, Position } from "geojson";
+
+import { stampSourceLayerFromProperty } from "@carma-mapping/utils";
+
+import { bboxOfParts, isAreaGeometry, partsOfGeometry } from "./origin";
+import { catalogLayerIdOfFeature } from "./scope";
+
+/**
+ * A navigable feature, as the addon keeps it between keypresses.
+ *
+ * Built once per map movement and then only read, which is the whole point:
+ * every keypress is arithmetic on these, never another renderer query. The
+ * renderer's query has three failure modes in this codebase — its symbol
+ * feature index overflows on large vector tiles, it fails for fill-extrusion
+ * layers under terrain, and it throws mid style-swap on stale layer ids — and
+ * querying per keypress would put all three into the interaction, dozens of
+ * times per key.
+ */
+export type NavCandidate = {
+ /** `source | sourceLayer | id`, the identity a feature keeps across tiles */
+ key: string;
+ styleLayerId: string;
+ catalogLayerId?: string;
+ source: string;
+ sourceLayer?: string;
+ /** the feature as queried, handed to the app's selection path unchanged */
+ feature: MapGeoJSONFeature;
+ /**
+ * Every piece of the feature that is on screen, as one geometry.
+ *
+ * `feature.geometry` is the share of the feature that sat in one tile: the
+ * renderer clips at tile borders, so a street or a large parcel arrives as
+ * several features with the same id. Whichever piece came first is not the
+ * feature, and an interior point taken from a thin end piece lands on the
+ * real feature's boundary. Ring grouping survives the merge, which is why
+ * this is kept next to the flat `parts` rather than derived from it: a hole
+ * has to stay a hole when the interior point is computed.
+ */
+ geometry: Geometry;
+ /** every ring, line or point of the feature present in the viewport, in lng/lat */
+ parts: Position[][];
+ isArea: boolean;
+ /** geographic bounding box, for the cheap per-keypress prune */
+ bbox: [number, number, number, number];
+};
+
+/**
+ * Two clipped pieces of one feature as a single geometry.
+ *
+ * Areas become a MultiPolygon whose parts are the pieces, so each piece keeps
+ * its own outer ring and holes and an interior point can be computed over the
+ * whole feature. Lines merge the same way. Mixed or unmergeable types keep the
+ * piece already held, since a wrong merge would be worse than a partial one.
+ */
+/**
+ * What makes a piece distinguishable from one already merged.
+ *
+ * The same feature comes back once per style layer that draws it — a fill and
+ * an outline are two layers over one polygon — and those copies are identical
+ * geometry, not further pieces of the feature. Merging them multiplies the work
+ * of every interior point by the number of layers. First and last vertex plus
+ * the ring shape identify a piece well enough: two genuinely different pieces
+ * of one feature cannot share them.
+ */
+const pieceSignature = (geometry: Geometry): string | undefined => {
+ const rings =
+ geometry.type === "Polygon"
+ ? geometry.coordinates
+ : geometry.type === "MultiPolygon"
+ ? geometry.coordinates.flat()
+ : geometry.type === "LineString"
+ ? [geometry.coordinates]
+ : geometry.type === "MultiLineString"
+ ? geometry.coordinates
+ : undefined;
+ const first = rings?.[0];
+ if (!rings || !first || first.length === 0) return undefined;
+ const start = first[0];
+ const end = first[first.length - 1];
+ return `${geometry.type}:${rings.length}:${first.length}:${start[0]},${start[1]}:${end[0]},${end[1]}`;
+};
+
+const mergeGeometries = (held: Geometry, incoming: Geometry): Geometry => {
+ const asPolygons = (geometry: Geometry): Position[][][] | undefined =>
+ geometry.type === "Polygon"
+ ? [geometry.coordinates]
+ : geometry.type === "MultiPolygon"
+ ? geometry.coordinates
+ : undefined;
+
+ const heldPolygons = asPolygons(held);
+ const incomingPolygons = asPolygons(incoming);
+ if (heldPolygons && incomingPolygons) {
+ return {
+ type: "MultiPolygon",
+ coordinates: [...heldPolygons, ...incomingPolygons],
+ };
+ }
+
+ const asLines = (geometry: Geometry): Position[][] | undefined =>
+ geometry.type === "LineString"
+ ? [geometry.coordinates]
+ : geometry.type === "MultiLineString"
+ ? geometry.coordinates
+ : undefined;
+
+ const heldLines = asLines(held);
+ const incomingLines = asLines(incoming);
+ if (heldLines && incomingLines) {
+ return {
+ type: "MultiLineString",
+ coordinates: [...heldLines, ...incomingLines],
+ };
+ }
+
+ return held;
+};
+
+export const candidateKeyOf = (feature: {
+ source?: string;
+ sourceLayer?: string;
+ id?: string | number;
+}): string | undefined =>
+ feature.id === undefined || feature.id === null || !feature.source
+ ? undefined
+ : `${feature.source}|${feature.sourceLayer ?? ""}|${String(feature.id)}`;
+
+export type CandidateSet = {
+ candidates: NavCandidate[];
+ byKey: Map;
+ /** the query hit its bound, or failed, so the set is not the whole truth */
+ degraded: boolean;
+};
+
+export const EMPTY_CANDIDATE_SET: CandidateSet = {
+ candidates: [],
+ byKey: new Map(),
+ degraded: false,
+};
+
+/**
+ * Queried features to candidates: deduplicated on `source | sourceLayer | id`,
+ * with the parts of one feature split across several tiles merged into a single
+ * candidate rather than the first tile's share of it.
+ *
+ * Features without an id are dropped. They cannot be deduplicated across tiles
+ * and cannot be addressed by the application's selection path, so navigating to
+ * one would select nothing.
+ */
+export const buildCandidates = (
+ features: MapGeoJSONFeature[],
+ {
+ catalogLayerIds,
+ requireCatalogLayer = false,
+ maxCandidates,
+ }: {
+ catalogLayerIds?: string[];
+ requireCatalogLayer?: boolean;
+ maxCandidates: number;
+ }
+): CandidateSet => {
+ const allowed = catalogLayerIds ? new Set(catalogLayerIds) : undefined;
+ const byKey = new Map();
+ /** pieces already merged per candidate, so layer duplicates are merged once */
+ const piecesByKey = new Map>();
+ let truncated = false;
+
+ for (const feature of features) {
+ stampSourceLayerFromProperty(feature);
+ const key = candidateKeyOf(feature);
+ if (!key) continue;
+
+ const catalogLayerId = catalogLayerIdOfFeature(feature);
+ // the style-layer filter of the query already narrows this; re-checked so a
+ // layer added outside the composer cannot leak into a scoped navigation
+ if (allowed && (!catalogLayerId || !allowed.has(catalogLayerId))) continue;
+ // the basemap draws vector features too, and they are not navigable
+ if (requireCatalogLayer && !catalogLayerId) continue;
+
+ const parts = partsOfGeometry(feature.geometry);
+ if (parts.length === 0) continue;
+
+ const signature = pieceSignature(feature.geometry);
+
+ const existing = byKey.get(key);
+ if (existing) {
+ const seen = piecesByKey.get(key);
+ // the same geometry again, from a second style layer over the same
+ // feature: nothing to merge, and merging it would multiply the work of
+ // every interior point by the number of layers that draw the feature
+ if (signature !== undefined && seen?.has(signature)) continue;
+ if (signature !== undefined) seen?.add(signature);
+
+ existing.parts.push(...parts);
+ existing.geometry = mergeGeometries(existing.geometry, feature.geometry);
+ const bbox = bboxOfParts(existing.parts);
+ if (bbox) existing.bbox = bbox;
+ continue;
+ }
+
+ if (byKey.size >= maxCandidates) {
+ truncated = true;
+ continue;
+ }
+
+ const bbox = bboxOfParts(parts);
+ if (!bbox) continue;
+
+ piecesByKey.set(
+ key,
+ new Set(signature === undefined ? [] : [signature])
+ );
+ byKey.set(key, {
+ key,
+ styleLayerId: feature.layer?.id ?? "",
+ ...(catalogLayerId ? { catalogLayerId } : {}),
+ source: feature.source,
+ ...(feature.sourceLayer ? { sourceLayer: feature.sourceLayer } : {}),
+ feature,
+ geometry: feature.geometry,
+ parts,
+ isArea: isAreaGeometry(feature.geometry.type),
+ bbox,
+ });
+ }
+
+ return {
+ candidates: [...byKey.values()],
+ byKey,
+ degraded: truncated,
+ };
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.spec.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.spec.ts
new file mode 100644
index 0000000000..8a7f996cbe
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.spec.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import { constantsForSharpness, resolveNavConstants } from "./constants";
+
+/** A4: the one knob, its anchors, and how an explicit override interacts. */
+describe("sharpness", () => {
+ it("produces the fuzzy anchor at 0", () => {
+ expect(constantsForSharpness(0)).toEqual({
+ coneAngleDeg: 75,
+ angleWeight: 1,
+ });
+ });
+
+ it("produces the strict anchor at 1", () => {
+ expect(constantsForSharpness(1)).toEqual({
+ coneAngleDeg: 40,
+ angleWeight: 6,
+ });
+ });
+
+ it("produces the defaults at 0.5, which is also the default sharpness", () => {
+ expect(constantsForSharpness(0.5)).toEqual({
+ coneAngleDeg: 60,
+ angleWeight: 2.5,
+ });
+ expect(resolveNavConstants()).toEqual({
+ coneAngleDeg: 60,
+ angleWeight: 2.5,
+ anglePower: 1,
+ });
+ });
+
+ it("interpolates linearly between the anchors", () => {
+ const quarter = constantsForSharpness(0.25);
+ expect(quarter.coneAngleDeg).toBeCloseTo(67.5, 6);
+ expect(quarter.angleWeight).toBeCloseTo(1.75, 6);
+ });
+
+ it("clamps rather than extrapolates outside 0..1", () => {
+ expect(constantsForSharpness(-1)).toEqual(constantsForSharpness(0));
+ expect(constantsForSharpness(2)).toEqual(constantsForSharpness(1));
+ });
+
+ it("lets an explicit cone angle win while the weight stays derived", () => {
+ expect(resolveNavConstants({ sharpness: 1, coneAngleDeg: 55 })).toEqual({
+ coneAngleDeg: 55,
+ angleWeight: 6,
+ anglePower: 1,
+ });
+ });
+
+ it("keeps anglePower out of the sharpness derivation", () => {
+ expect(resolveNavConstants({ sharpness: 0 }).anglePower).toBe(1);
+ expect(
+ resolveNavConstants({ sharpness: 1, anglePower: 2 }).anglePower
+ ).toBe(2);
+ });
+});
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts
new file mode 100644
index 0000000000..4b46768081
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts
@@ -0,0 +1,131 @@
+import type {
+ FeatureKeyboardNavConfig,
+ NavExplainMode,
+ ResolvedNavConstants,
+} from "./types";
+
+/**
+ * The one knob and the three constants behind it.
+ *
+ * A config author says how strictly navigation should follow the axis with a
+ * single `sharpness` between 0 and 1; `coneAngleDeg` and `angleWeight` are
+ * interpolated from it. Both remain settable on their own, and an explicit
+ * value always wins, so a deployment can start from the preset and correct one
+ * term. `anglePower` is not driven by `sharpness`.
+ */
+
+/**
+ * Fuzzy is a wide cone with a weak penalty: whatever is physically near wins,
+ * poor alignment tolerated — right for scattered points. Strict is a narrow
+ * cone with a heavy penalty: navigation follows the axis and only accepts an
+ * off-axis feature that is dramatically closer — right for gridded data.
+ */
+export const SHARPNESS_ANCHORS: readonly {
+ sharpness: number;
+ coneAngleDeg: number;
+ angleWeight: number;
+}[] = [
+ { sharpness: 0, coneAngleDeg: 75, angleWeight: 1 },
+ { sharpness: 0.5, coneAngleDeg: 60, angleWeight: 2.5 },
+ { sharpness: 1, coneAngleDeg: 40, angleWeight: 6 },
+];
+
+export const DEFAULT_SHARPNESS = 0.5;
+/** linear across the cone; 2 makes small deviations nearly free */
+export const DEFAULT_ANGLE_POWER = 1;
+export const DEFAULT_STRATEGY = "auto";
+export const DEFAULT_FAN_DEG = 8;
+export const DEFAULT_MIN_STEP_PX = 2;
+export const DEFAULT_CROSS_LAYER = "prefer-current";
+export const DEFAULT_CURRENT_LAYER_BONUS = 0.6;
+/** the pressed direction outranks its fan unless the fan is clearly nearer */
+export const DEFAULT_CENTER_RAY_BONUS = 0.85;
+export const DEFAULT_VERIFY_MAX_RETRIES = 3;
+export const DEFAULT_EDGE_BEHAVIOR = "pan";
+export const DEFAULT_PAN_STEP_FRACTION = 0.5;
+export const DEFAULT_PAN_DURATION_MS = 300;
+export const DEFAULT_EXPLAIN = "brief";
+export const DEFAULT_EXPLAIN_MS = 1200;
+
+/**
+ * What the switch on the legend steps through, in order.
+ *
+ * `config.explain` decides whether the readout and its switch exist at all;
+ * this decides what the switch offers once they do. "off" is part of the cycle
+ * and not an exit: it drops the picture and keeps the readout, because the
+ * numbers describe the mode that is running rather than the last keypress.
+ */
+export const EXPLAIN_MODE_CYCLE = [
+ "brief",
+ "hold",
+ "off",
+] as const satisfies readonly NavExplainMode[];
+
+export const nextExplainMode = (mode: NavExplainMode): NavExplainMode =>
+ EXPLAIN_MODE_CYCLE[
+ (EXPLAIN_MODE_CYCLE.indexOf(mode) + 1) % EXPLAIN_MODE_CYCLE.length
+ ];
+/** the blue the selection itself is drawn in, since it marks the selection */
+export const DEFAULT_ORIGIN_DOT_COLOR = "#1677ff";
+export const DEFAULT_ORIGIN_DOT_OPACITY = 1;
+export const DEFAULT_MAX_CANDIDATES = 4000;
+export const DEFAULT_CANDIDATE_DEBOUNCE_MS = 200;
+
+/** Pixels a Shift+arrow moves the map, matching MapLibre's own keyboard pan. */
+export const SHIFT_PAN_PX = 100;
+
+/**
+ * Share of the viewport the selection is kept inside after a step. A selection
+ * landing outside this rectangle is panned back in; zoom is never touched.
+ */
+export const KEEP_IN_VIEW_INSET_FRACTION = 0.12;
+
+/** How long an edge pan is waited out before the retry gives up. */
+export const EDGE_PAN_SETTLE_TIMEOUT_MS = 2000;
+
+const clamp = (value: number, min: number, max: number) =>
+ Math.min(max, Math.max(min, value));
+
+const lerp = (from: number, to: number, t: number) => from + (to - from) * t;
+
+/**
+ * `coneAngleDeg` and `angleWeight` for a sharpness, interpolated linearly
+ * between the neighbouring anchors. Values outside 0..1 are clamped rather than
+ * extrapolated: beyond the anchors the constants stop meaning anything.
+ */
+export const constantsForSharpness = (
+ sharpness: number
+): { coneAngleDeg: number; angleWeight: number } => {
+ const value = clamp(sharpness, 0, 1);
+ for (let index = 1; index < SHARPNESS_ANCHORS.length; index++) {
+ const lower = SHARPNESS_ANCHORS[index - 1];
+ const upper = SHARPNESS_ANCHORS[index];
+ if (value <= upper.sharpness) {
+ const span = upper.sharpness - lower.sharpness;
+ const t = span === 0 ? 0 : (value - lower.sharpness) / span;
+ return {
+ coneAngleDeg: lerp(lower.coneAngleDeg, upper.coneAngleDeg, t),
+ angleWeight: lerp(lower.angleWeight, upper.angleWeight, t),
+ };
+ }
+ }
+ const last = SHARPNESS_ANCHORS[SHARPNESS_ANCHORS.length - 1];
+ return { coneAngleDeg: last.coneAngleDeg, angleWeight: last.angleWeight };
+};
+
+/**
+ * The constants actually in force: derived from `sharpness`, then overridden
+ * term by term. Setting both is legal and the explicit value wins, so
+ * `{ sharpness: 1, coneAngleDeg: 55 }` is "strict, but with a wider cone" and
+ * keeps the strict `angleWeight`.
+ */
+export const resolveNavConstants = (
+ config: FeatureKeyboardNavConfig = {}
+): ResolvedNavConstants => {
+ const derived = constantsForSharpness(config.sharpness ?? DEFAULT_SHARPNESS);
+ return {
+ coneAngleDeg: config.coneAngleDeg ?? derived.coneAngleDeg,
+ angleWeight: config.angleWeight ?? derived.angleWeight,
+ anglePower: config.anglePower ?? DEFAULT_ANGLE_POWER,
+ };
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts
new file mode 100644
index 0000000000..76eea79143
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts
@@ -0,0 +1,273 @@
+import type { ProjectedCandidate, ScreenPoint } from "./types";
+
+/**
+ * Screen-space vector maths for the picking core.
+ *
+ * Everything here works on plain pixel coordinates and knows nothing about
+ * maps, projections or geography. Directions are screen directions: `y` grows
+ * downwards, so "up" is `(0, -1)` and a bearing never enters the calculation.
+ */
+
+/** Below this the two points are treated as one; guards the angle's division. */
+const EPSILON = 1e-9;
+
+export const subtract = (a: ScreenPoint, b: ScreenPoint): ScreenPoint => ({
+ x: a.x - b.x,
+ y: a.y - b.y,
+});
+
+export const dot = (a: ScreenPoint, b: ScreenPoint): number =>
+ a.x * b.x + a.y * b.y;
+
+/** z of the 2D cross product; its sign is the turn direction */
+export const cross = (a: ScreenPoint, b: ScreenPoint): number =>
+ a.x * b.y - a.y * b.x;
+
+export const length = (v: ScreenPoint): number => Math.hypot(v.x, v.y);
+
+export const normalize = (v: ScreenPoint): ScreenPoint => {
+ const len = length(v);
+ return len < EPSILON ? { x: 0, y: 0 } : { x: v.x / len, y: v.y / len };
+};
+
+/**
+ * `v` rotated by `deg`, in the screen's coordinate system. Positive turns from
+ * +x towards +y, which is clockwise on screen; the fan of `first-crossed` uses
+ * it symmetrically, so the handedness does not matter there.
+ */
+export const rotate = (v: ScreenPoint, deg: number): ScreenPoint => {
+ const rad = (deg * Math.PI) / 180;
+ const cos = Math.cos(rad);
+ const sin = Math.sin(rad);
+ return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
+};
+
+/** Unsigned angle between two vectors in degrees, range [0, 180]. */
+export const angleBetweenDeg = (a: ScreenPoint, b: ScreenPoint): number => {
+ const lengths = length(a) * length(b);
+ if (lengths < EPSILON) return 0;
+ return (
+ (Math.acos(Math.min(1, Math.max(-1, dot(a, b) / lengths))) * 180) / Math.PI
+ );
+};
+
+/** Signed angle from `from` to `to` in degrees, range (-180, 180]. */
+export const signedAngleDeg = (from: ScreenPoint, to: ScreenPoint): number =>
+ (Math.atan2(cross(from, to), dot(from, to)) * 180) / Math.PI;
+
+/** The point of segment `a`→`b` closest to `p`. */
+export const nearestPointOnSegment = (
+ p: ScreenPoint,
+ a: ScreenPoint,
+ b: ScreenPoint
+): ScreenPoint => {
+ const ab = subtract(b, a);
+ const lengthSquared = dot(ab, ab);
+ if (lengthSquared < EPSILON) return a;
+ const t = Math.min(1, Math.max(0, dot(subtract(p, a), ab) / lengthSquared));
+ return { x: a.x + ab.x * t, y: a.y + ab.y * t };
+};
+
+/**
+ * The point of a candidate's outline closest to `origin`.
+ *
+ * Measured against the whole outline, not a centroid: a large parcel sharing a
+ * border with the origin is then at distance ~0 instead of being pushed away by
+ * its own extent, and points, lines and polygons become comparable without any
+ * special casing. Single-coordinate parts (point features) reduce to that
+ * coordinate.
+ */
+export const nearestPointOfCandidate = (
+ origin: ScreenPoint,
+ candidate: ProjectedCandidate
+): ScreenPoint | undefined => {
+ let best: ScreenPoint | undefined;
+ let bestDistanceSquared = Infinity;
+
+ const consider = (point: ScreenPoint) => {
+ const delta = subtract(point, origin);
+ const distanceSquared = dot(delta, delta);
+ if (distanceSquared < bestDistanceSquared) {
+ bestDistanceSquared = distanceSquared;
+ best = point;
+ }
+ };
+
+ for (const part of candidate.parts) {
+ if (part.length === 0) continue;
+ if (part.length === 1) {
+ consider(part[0]);
+ continue;
+ }
+ for (let index = 1; index < part.length; index++) {
+ consider(nearestPointOnSegment(origin, part[index - 1], part[index]));
+ }
+ }
+
+ return best;
+};
+
+/**
+ * Distance along the ray at which it first crosses a candidate's outline, or
+ * `undefined` when it never does.
+ *
+ * The ray is `origin + t·direction` with `direction` a unit vector, so `t` is
+ * already in pixels. A segment parallel to the ray is skipped rather than
+ * guessed at — the degenerate case of a ray running exactly along a shared
+ * border is what the three-ray fan is there for.
+ */
+export const firstCrossing = (
+ origin: ScreenPoint,
+ direction: ScreenPoint,
+ candidate: ProjectedCandidate,
+ maxDistance: number
+): { t: number; point: ScreenPoint } | undefined => {
+ let bestT = Infinity;
+
+ for (const part of candidate.parts) {
+ for (let index = 1; index < part.length; index++) {
+ const a = part[index - 1];
+ const b = part[index];
+ const segment = subtract(b, a);
+ const denominator = cross(direction, segment);
+ if (Math.abs(denominator) < EPSILON) continue;
+ const toA = subtract(a, origin);
+ const t = cross(toA, segment) / denominator;
+ const s = cross(toA, direction) / denominator;
+ if (t > EPSILON && t <= maxDistance && s >= 0 && s <= 1 && t < bestT) {
+ bestT = t;
+ }
+ }
+ }
+
+ if (bestT === Infinity) return undefined;
+ return {
+ t: bestT,
+ point: {
+ x: origin.x + direction.x * bestT,
+ y: origin.y + direction.y * bestT,
+ },
+ };
+};
+
+/**
+ * The middle of the first stretch the ray spends *inside* a candidate.
+ *
+ * Crossings along a ray alternate outside/inside, so the segment between the
+ * first and the second one lies within the candidate — holes included, since a
+ * hole's ring is crossed like any other boundary. Its midpoint is therefore
+ * strictly inside, and it is inside *where the walk arrived*, which a pole of
+ * inaccessibility is not: entering a long street from the south, the pole can
+ * sit hundreds of metres down the road, and the next step then measures from
+ * there instead of from the place the user is looking at.
+ *
+ * `undefined` when the ray does not pass through the candidate at all, which
+ * leaves the caller with its pole as the origin.
+ */
+export const chordMidpoint = (
+ origin: ScreenPoint,
+ direction: ScreenPoint,
+ candidate: ProjectedCandidate,
+ maxDistance: number
+): ScreenPoint | undefined => {
+ let firstT = Infinity;
+ let secondT = Infinity;
+
+ for (const part of candidate.parts) {
+ for (let index = 1; index < part.length; index++) {
+ const a = part[index - 1];
+ const b = part[index];
+ const segment = subtract(b, a);
+ const denominator = cross(direction, segment);
+ if (Math.abs(denominator) < EPSILON) continue;
+ const toA = subtract(a, origin);
+ const t = cross(toA, segment) / denominator;
+ const s = cross(toA, direction) / denominator;
+ if (t <= EPSILON || t > maxDistance || s < 0 || s > 1) continue;
+ if (t < firstT) {
+ secondT = firstT;
+ firstT = t;
+ } else if (t < secondT && t > firstT + EPSILON) {
+ secondT = t;
+ }
+ }
+ }
+
+ if (firstT === Infinity || secondT === Infinity) return undefined;
+ const middle = (firstT + secondT) / 2;
+ return {
+ x: origin.x + direction.x * middle,
+ y: origin.y + direction.y * middle,
+ };
+};
+
+/** An axis-aligned rectangle in screen pixels. */
+export type ScreenBox = {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+};
+
+export const boxOfPoints = (points: ScreenPoint[]): ScreenBox => {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const { x, y } of points) {
+ if (x < minX) minX = x;
+ if (y < minY) minY = y;
+ if (x > maxX) maxX = x;
+ if (y > maxY) maxY = y;
+ }
+ return { minX, minY, maxX, maxY };
+};
+
+const cornersOf = (box: ScreenBox): ScreenPoint[] => [
+ { x: box.minX, y: box.minY },
+ { x: box.maxX, y: box.minY },
+ { x: box.maxX, y: box.maxY },
+ { x: box.minX, y: box.maxY },
+];
+
+/**
+ * Cheap rejection of a whole candidate from its bounding box alone, before its
+ * rings are projected.
+ *
+ * Conservative by construction: it only rejects when *no* point of the box can
+ * satisfy the gate, so a candidate it drops could not have won. The minimum
+ * angle over a convex box seen from an outside point is attained at a corner,
+ * unless the axis itself passes through the box, in which case it is zero.
+ */
+export const boxRejection = (
+ box: ScreenBox,
+ origin: ScreenPoint,
+ axis: ScreenPoint,
+ coneAngleDeg: number
+): "behind-origin" | "outside-cone" | undefined => {
+ const corners = cornersOf(box);
+
+ const originInside =
+ origin.x >= box.minX &&
+ origin.x <= box.maxX &&
+ origin.y >= box.minY &&
+ origin.y <= box.maxY;
+ if (originInside) return undefined;
+
+ // every corner behind the origin means the whole box is behind it
+ if (corners.every((corner) => dot(subtract(corner, origin), axis) <= 0)) {
+ return "behind-origin";
+ }
+
+ const angles = corners.map((corner) =>
+ signedAngleDeg(axis, subtract(corner, origin))
+ );
+ const min = Math.min(...angles);
+ const max = Math.max(...angles);
+ // the box straddles the axis, or wraps far enough that the signed angles are
+ // no longer an interval: it may reach the axis, so keep it
+ if ((min <= 0 && max >= 0) || max - min >= 180) return undefined;
+
+ const smallest = Math.min(Math.abs(min), Math.abs(max));
+ return smallest > coneAngleDeg ? "outside-cone" : undefined;
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/keymap.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/keymap.ts
new file mode 100644
index 0000000000..3cef7b3bb1
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/keymap.ts
@@ -0,0 +1,150 @@
+import type { NavDirection } from "./types";
+
+/**
+ * The keymap as data rather than as a switch statement.
+ *
+ * The deferred entries of the spec — Backspace for undo, Tab for reading-order
+ * traversal — then become rows in this table instead of a rewrite of the key
+ * handler, and the help chip renders the same rows the handler matches.
+ */
+
+export type NavAction = "step" | "pan" | "exit" | "activate";
+
+export type NavKeyBinding = {
+ /** `KeyboardEvent.key` values this row matches */
+ keys: readonly string[];
+ /** required Shift state; `undefined` matches either */
+ shift?: boolean;
+ action: NavAction;
+ direction?: NavDirection;
+ /** only bound when the host supplied a handler for it */
+ requiresHandler?: boolean;
+ /** what the row does, for the help chip */
+ label: string;
+};
+
+const ARROWS: Readonly> = {
+ ArrowUp: "up",
+ ArrowDown: "down",
+ ArrowLeft: "left",
+ ArrowRight: "right",
+};
+
+const stepRows: NavKeyBinding[] = Object.entries(ARROWS).map(
+ ([key, direction]) => ({
+ keys: [key],
+ shift: false,
+ action: "step",
+ direction,
+ label: "Objekt in Pfeilrichtung wählen",
+ })
+);
+
+/**
+ * Shift+arrow keeps panning available while the mode runs: entering navigation
+ * must not permanently take arrow-key panning away from the user, and the map's
+ * own keyboard handler is off for as long as the mode is on.
+ */
+const panRows: NavKeyBinding[] = Object.entries(ARROWS).map(
+ ([key, direction]) => ({
+ keys: [key],
+ shift: true,
+ action: "pan",
+ direction,
+ label: "Karte verschieben",
+ })
+);
+
+export const NAV_KEYMAP: readonly NavKeyBinding[] = [
+ ...stepRows,
+ ...panRows,
+ {
+ keys: ["Escape"],
+ action: "exit",
+ label: "Auswahl aufheben und Modus verlassen",
+ },
+ {
+ keys: ["Enter"],
+ action: "activate",
+ requiresHandler: true,
+ label: "Objekt öffnen",
+ },
+];
+
+const KEY_GLYPHS: Readonly> = {
+ ArrowUp: "↑",
+ ArrowDown: "↓",
+ ArrowLeft: "←",
+ ArrowRight: "→",
+ Escape: "Esc",
+ Enter: "⏎",
+};
+
+/**
+ * The keymap as the help chip shows it: one row per action, its keys collapsed
+ * into one group. Built from `NAV_KEYMAP`, so a row added there appears in the
+ * help without a second edit.
+ */
+export const navHintRows = ({
+ hasActivateHandler,
+}: {
+ hasActivateHandler: boolean;
+}): { label: string; keys: string }[] => {
+ const rows = new Map();
+ for (const binding of NAV_KEYMAP) {
+ if (binding.requiresHandler && !hasActivateHandler) continue;
+ const row = rows.get(binding.label) ?? {
+ glyphs: [],
+ shift: binding.shift === true,
+ };
+ for (const key of binding.keys) {
+ const glyph = KEY_GLYPHS[key] ?? key;
+ if (!row.glyphs.includes(glyph)) row.glyphs.push(glyph);
+ }
+ rows.set(binding.label, row);
+ }
+ return [...rows.entries()].map(([label, { glyphs, shift }]) => ({
+ label,
+ keys: `${shift ? "Shift+" : ""}${glyphs.join("")}`,
+ }));
+};
+
+/**
+ * A key event must not reach navigation while the user is typing. Both the
+ * event target and the focused element are checked: a key event can be
+ * dispatched at the document while focus sits in a field.
+ */
+export const isTypingTarget = (target: EventTarget | null): boolean => {
+ const asElement = (value: unknown): Element | null =>
+ value instanceof Element
+ ? value
+ : value instanceof Node
+ ? value.parentElement
+ : null;
+
+ const isEditable = (element: Element | null): boolean =>
+ element instanceof HTMLElement &&
+ (element.isContentEditable ||
+ element.closest(
+ "input, textarea, select, [contenteditable]:not([contenteditable='false'])"
+ ) !== null);
+
+ return isEditable(asElement(target)) || isEditable(document.activeElement);
+};
+
+/**
+ * The row a key event triggers, or `undefined` when navigation ignores it.
+ * Modifier combinations other than Shift are left to the browser and the app.
+ */
+export const resolveNavBinding = (
+ event: KeyboardEvent,
+ { hasActivateHandler }: { hasActivateHandler: boolean }
+): NavKeyBinding | undefined => {
+ if (event.ctrlKey || event.metaKey || event.altKey) return undefined;
+ return NAV_KEYMAP.find(
+ (binding) =>
+ binding.keys.includes(event.key) &&
+ (binding.shift === undefined || binding.shift === event.shiftKey) &&
+ (!binding.requiresHandler || hasActivateHandler)
+ );
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts
new file mode 100644
index 0000000000..450efad748
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts
@@ -0,0 +1,170 @@
+import { booleanPointInPolygon, centroid } from "@turf/turf";
+import type { Feature, Polygon } from "geojson";
+import { describe, expect, it } from "vitest";
+
+import { resolveNavConstants } from "./constants";
+import { normalize, subtract } from "./geometry";
+import { interiorPointOf } from "./origin";
+import { pickInDirection } from "./pick";
+import type { PickInput, ScreenPoint } from "./types";
+
+/**
+ * A6: the origin has to lie inside the selected feature.
+ *
+ * A C-shaped polygon has its centroid in its own concavity, i.e. outside
+ * itself, and every direction measured from there is inverted. The coordinates
+ * double as screen pixels here — the picking core is coordinate-system
+ * agnostic, which is exactly what makes this testable without a map.
+ */
+
+/** a 3x3 square with the right half of its middle row cut out */
+const cShape: Feature = {
+ type: "Feature",
+ properties: {},
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [0, 0],
+ [3, 0],
+ [3, 1],
+ [1, 1],
+ [1, 2],
+ [3, 2],
+ [3, 3],
+ [0, 3],
+ [0, 0],
+ ],
+ ],
+ },
+};
+
+const centroidOf = (): ScreenPoint => {
+ const [x, y] = centroid(cShape).geometry.coordinates;
+ return { x, y };
+};
+
+const inputFor = (
+ origin: ScreenPoint,
+ axis: ScreenPoint,
+ at: ScreenPoint
+): PickInput => ({
+ origin,
+ axis,
+ candidates: [{ key: "neighbour", isArea: false, parts: [[at]] }],
+ constants: resolveNavConstants(),
+ originIsArea: false,
+ strategy: "nearest-in-cone",
+ crossLayer: "free",
+ currentLayerBonus: 0.6,
+ centerRayBonus: 0.85,
+ minStepPx: 0.001,
+ fanDeg: 8,
+ rayLengthPx: 100,
+});
+
+/** a corridor: 40 long, 2 wide, with a bulge near its left end */
+const corridor: Feature = {
+ type: "Feature",
+ properties: {},
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [0, 0],
+ [5, 0],
+ [5, -3],
+ [8, -3],
+ [8, 0],
+ [40, 0],
+ [40, 2],
+ [0, 2],
+ [0, 0],
+ ],
+ ],
+ },
+};
+
+describe("origin strategies", () => {
+ it("puts the spine half way along a corridor, not in its bulge", () => {
+ const pole = interiorPointOf(corridor.geometry, "pole") as number[];
+ const spine = interiorPointOf(corridor.geometry, "spine") as number[];
+
+ // the bulge is the roomiest place, so that is where the pole sits
+ expect(pole[0]).toBeLessThan(10);
+ // the spine follows the length instead, so it lands near the middle
+ expect(spine[0]).toBeGreaterThan(15);
+ expect(spine[0]).toBeLessThan(25);
+ expect(booleanPointInPolygon(spine, corridor, { ignoreBoundary: true })).toBe(
+ true
+ );
+ });
+
+ it("falls back to the pole where the centroid lies outside", () => {
+ // the C-shape has its centroid in its own concavity
+ expect(booleanPointInPolygon(centroid(cShape), cShape)).toBe(false);
+
+ const fromCentroid = interiorPointOf(cShape.geometry, "centroid") as number[];
+ const pole = interiorPointOf(cShape.geometry, "pole") as number[];
+
+ expect(fromCentroid).toEqual(pole);
+ expect(
+ booleanPointInPolygon(fromCentroid, cShape, { ignoreBoundary: true })
+ ).toBe(true);
+ });
+
+ it("uses the centroid where it lies inside", () => {
+ const square: Feature = {
+ type: "Feature",
+ properties: {},
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [0, 0],
+ [4, 0],
+ [4, 4],
+ [0, 4],
+ [0, 0],
+ ],
+ ],
+ },
+ };
+
+ expect(interiorPointOf(square.geometry, "centroid")).toEqual([2, 2]);
+ });
+});
+
+describe("interior origin", () => {
+ it("returns a point on the feature where the centroid is outside it", () => {
+ const interior = interiorPointOf(cShape.geometry);
+ expect(interior).toBeDefined();
+
+ expect(booleanPointInPolygon(interior as number[], cShape)).toBe(true);
+ expect(booleanPointInPolygon(centroid(cShape), cShape)).toBe(false);
+ });
+
+ it("inverts every direction when the centroid is used instead", () => {
+ const [ix, iy] = interiorPointOf(cShape.geometry) as number[];
+ const interior: ScreenPoint = { x: ix, y: iy };
+ const outside = centroidOf();
+
+ // a neighbour halfway between the two: from the interior point it lies
+ // towards the centroid, from the centroid it lies in the opposite direction
+ const neighbour: ScreenPoint = {
+ x: (interior.x + outside.x) / 2,
+ y: (interior.y + outside.y) / 2,
+ };
+ const axis = normalize(subtract(outside, interior));
+
+ expect(pickInDirection(inputFor(interior, axis, neighbour)).winnerKey).toBe(
+ "neighbour"
+ );
+ // same key, same neighbour, centroid origin: it is now behind the axis
+ const fromCentroid = pickInDirection(inputFor(outside, axis, neighbour));
+ expect(fromCentroid.winnerKey).toBeUndefined();
+ expect(fromCentroid.explanation.evaluations[0].rejectedBecause).toBe(
+ "behind-origin"
+ );
+ });
+});
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts
new file mode 100644
index 0000000000..9a634df305
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts
@@ -0,0 +1,384 @@
+import { pointOnFeature } from "@turf/turf";
+import type { Feature, Geometry, Position } from "geojson";
+import polylabel from "polylabel";
+
+/**
+ * The point navigation measures from.
+ *
+ * It has to lie *inside* the selected feature. A centroid does not: a C-shaped
+ * or ring-shaped parcel has its centroid outside its own geometry, and every
+ * direction around it is then inverted — the neighbour to the left is measured
+ * as lying to the right.
+ *
+ * For areas that is polylabel, the pole of inaccessibility: the point furthest
+ * from any edge. `pointOnFeature` only guarantees *on* the feature, and falls
+ * back to a boundary vertex whenever the bbox centre misses — which happens on
+ * every concave parcel and on every shape a vector tile clipped. A boundary
+ * origin sits on the border it is supposed to step across, so a ray leaves the
+ * feature at 0 px and the first neighbour it meets is arbitrary. The deepest
+ * interior point is the stable one.
+ */
+
+/** Points and lines have no interior; `pointOnFeature` is right for them. */
+const onFeaturePointOf = (geometry: Geometry): [number, number] | undefined => {
+ try {
+ const feature: Feature = { type: "Feature", properties: {}, geometry };
+ const [lng, lat] = pointOnFeature(feature).geometry.coordinates;
+ return Number.isFinite(lng) && Number.isFinite(lat)
+ ? [lng, lat]
+ : undefined;
+ } catch {
+ // degenerate geometry (empty rings, NaN coordinates) from a broken tile
+ return undefined;
+ }
+};
+
+/**
+ * Which point inside a feature the navigation measures from.
+ *
+ * Configurable rather than decided here, because which one reads as "the
+ * middle" depends on the shape and there is no winner across all of them. No
+ * automatic choice yet: pick one, look at real parcels, then decide.
+ *
+ * - `pole`: furthest from any edge. The only one with a clearance guarantee,
+ * and meaningless on a corridor, where every point is equally far from the
+ * two long sides and the widest spot is wherever a junction happens to bulge.
+ * - `spine`: the middle *along* the shape. On a street that is the point half
+ * way down it, which is what a reader expects and what the pole is not.
+ * - `centroid`: the plain area centroid. Right for compact shapes, outside the
+ * feature for anything bent, which is why it falls back.
+ */
+export type OriginStrategy = "pole" | "spine" | "centroid";
+
+/** A single polygon: ring 0 is the outer ring, the rest are holes. */
+type PolygonRings = Position[][];
+
+/** The pole of one polygon and how far it sits from the nearest edge. */
+type PartPole = { point: [number, number]; clearance: number };
+
+const extentOfRing = (ring: Position[]) => {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const [x, y] of ring) {
+ if (x < minX) minX = x;
+ if (y < minY) minY = y;
+ if (x > maxX) maxX = x;
+ if (y > maxY) maxY = y;
+ }
+ return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
+};
+
+const poleOfPart = (rings: PolygonRings): PartPole | undefined => {
+ const outer = rings[0];
+ if (!outer || outer.length < 4) return undefined;
+
+ try {
+ const { width, height } = extentOfRing(outer);
+ // polylabel's precision is in coordinate units and defaults to 1.0, which
+ // is ~111 km in EPSG:4326 and stops the search instantly. Deriving it from
+ // the polygon keeps this correct in degrees and in metres alike.
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined;
+ if (width <= 0 || height <= 0) return undefined;
+
+ /**
+ * Scaled by the narrow side, not by the extent.
+ *
+ * polylabel starts from cells of `min(width, height)` and quarters them
+ * until one falls below the precision, so the work grows with the square of
+ * that ratio: a fixed ratio is a fixed number of refinement rounds no matter
+ * how long or bent the polygon is. Scaling by the extent instead ties the
+ * two together, and on a long thin parcel the precision then exceeds the
+ * clearance the polygon can offer at all — polylabel returns a distance near
+ * zero, the guard below rejects it, and the origin falls back to a boundary
+ * vertex, which is the very thing this function exists to avoid.
+ *
+ * A fiftieth of the narrow side leaves the point visually centred (the dot
+ * is a few pixels wide) while bounding the search at about six rounds. The
+ * measured pathological case, a 40 m parcel with five holes whose queue
+ * prunes nothing, went from 1461 ms to under 20 ms.
+ */
+ const result = polylabel(rings, Math.min(width, height) / 50);
+ const [lng, lat] = result;
+ const clearance = result.distance;
+
+ // `distance` is the signed distance to the nearest edge, so > 0 is
+ // polylabel's own proof that the point lies strictly inside. Rejects the
+ // degenerate early-exits too.
+ if (!Number.isFinite(lng) || !Number.isFinite(lat)) return undefined;
+ if (!(clearance > 0)) return undefined;
+ return { point: [lng, lat], clearance };
+ } catch {
+ // degenerate geometry (empty rings, NaN coordinates) from a broken tile
+ return undefined;
+ }
+};
+
+/**
+ * Even-odd test against every ring, holes included.
+ *
+ * The two strategies that are not the pole have no containment guarantee of
+ * their own, so each has to be checked before it is used.
+ */
+const isInsideRings = (rings: PolygonRings, [x, y]: [number, number]) => {
+ let inside = false;
+ for (const ring of rings) {
+ for (let index = 1; index < ring.length; index++) {
+ const [x1, y1] = ring[index - 1];
+ const [x2, y2] = ring[index];
+ if (y1 > y === y2 > y) continue;
+ if (x < ((x2 - x1) * (y - y1)) / (y2 - y1) + x1) inside = !inside;
+ }
+ }
+ return inside;
+};
+
+/** Area centroid of the outer ring, holes ignored. */
+const centroidOfPart = (rings: PolygonRings): [number, number] | undefined => {
+ const ring = rings[0];
+ if (!ring || ring.length < 4) return undefined;
+
+ let twiceArea = 0;
+ let x = 0;
+ let y = 0;
+ for (let index = 1; index < ring.length; index++) {
+ const [x1, y1] = ring[index - 1];
+ const [x2, y2] = ring[index];
+ const cross = x1 * y2 - x2 * y1;
+ twiceArea += cross;
+ x += (x1 + x2) * cross;
+ y += (y1 + y2) * cross;
+ }
+ if (twiceArea === 0) return undefined;
+ return [x / (3 * twiceArea), y / (3 * twiceArea)];
+};
+
+/**
+ * The middle *along* the shape, rather than the widest point in it.
+ *
+ * The two ends are found by walking away from the shape twice: the vertex
+ * furthest from the centroid is one end, the vertex furthest from that one is
+ * the other. They cut the outer ring into two chains, one per long side of a
+ * corridor, and the arc-length midpoint of each chain is the point half way
+ * down that side. Their average is the middle of the street.
+ *
+ * O(n) and without a containment guarantee: on a shape that folds back past
+ * its own middle the average can land outside, which the caller checks for.
+ */
+const spineOfPart = (rings: PolygonRings): [number, number] | undefined => {
+ const ring = rings[0];
+ if (!ring || ring.length < 4) return undefined;
+ // the ring is closed, so the repeated last vertex is dropped
+ const vertices = ring.slice(0, -1);
+ if (vertices.length < 3) return undefined;
+
+ const centroid = centroidOfPart(rings);
+ if (!centroid) return undefined;
+
+ const squaredDistance = (a: Position, b: Position | [number, number]) =>
+ (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
+
+ const furthestFrom = (from: Position | [number, number]) => {
+ let bestIndex = 0;
+ let bestDistance = -Infinity;
+ vertices.forEach((vertex, index) => {
+ const distance = squaredDistance(vertex, from);
+ if (distance > bestDistance) {
+ bestDistance = distance;
+ bestIndex = index;
+ }
+ });
+ return bestIndex;
+ };
+
+ const startIndex = furthestFrom(centroid);
+ const endIndex = furthestFrom(vertices[startIndex]);
+ if (startIndex === endIndex) return undefined;
+
+ /** the point half way along a chain of vertices, by arc length */
+ const midpointOfChain = (indices: number[]): [number, number] | undefined => {
+ if (indices.length < 2) return undefined;
+ let total = 0;
+ for (let step = 1; step < indices.length; step++) {
+ total += Math.sqrt(
+ squaredDistance(vertices[indices[step - 1]], vertices[indices[step]])
+ );
+ }
+ if (total === 0) return undefined;
+
+ let walked = 0;
+ for (let step = 1; step < indices.length; step++) {
+ const a = vertices[indices[step - 1]];
+ const b = vertices[indices[step]];
+ const segment = Math.sqrt(squaredDistance(a, b));
+ if (walked + segment >= total / 2) {
+ const share = segment === 0 ? 0 : (total / 2 - walked) / segment;
+ return [a[0] + (b[0] - a[0]) * share, a[1] + (b[1] - a[1]) * share];
+ }
+ walked += segment;
+ }
+ return undefined;
+ };
+
+ const forward: number[] = [];
+ for (
+ let index = startIndex;
+ index !== endIndex;
+ index = (index + 1) % vertices.length
+ ) {
+ forward.push(index);
+ }
+ forward.push(endIndex);
+
+ const backward: number[] = [];
+ for (
+ let index = startIndex;
+ index !== endIndex;
+ index = (index - 1 + vertices.length) % vertices.length
+ ) {
+ backward.push(index);
+ }
+ backward.push(endIndex);
+
+ const first = midpointOfChain(forward);
+ const second = midpointOfChain(backward);
+ if (!first || !second) return undefined;
+ return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2];
+};
+
+/**
+ * The roomiest polygon of a geometry, with its pole.
+ *
+ * The roomiest one carries the origin, so an island never steals it from the
+ * mainland, and it is the polygon every strategy then works on.
+ */
+const bestPartOf = (geometry: Geometry) => {
+ const parts: PolygonRings[] =
+ geometry.type === "Polygon"
+ ? [geometry.coordinates]
+ : geometry.type === "MultiPolygon"
+ ? geometry.coordinates
+ : [];
+
+ let rings: PolygonRings | undefined;
+ let pole: PartPole | undefined;
+ for (const candidate of parts) {
+ const candidatePole = poleOfPart(candidate);
+ if (!candidatePole) continue;
+ if (!pole || candidatePole.clearance > pole.clearance) {
+ pole = candidatePole;
+ rings = candidate;
+ }
+ }
+ return { rings, pole };
+};
+
+/** A strategy's point, and whether it may be used as it stands. */
+export type OriginCandidate = { point: [number, number]; inside: boolean };
+
+/**
+ * Every strategy's point for one geometry, for drawing them side by side.
+ *
+ * Points that fall outside are kept rather than dropped, marked `inside:
+ * false`: seeing where a strategy went wrong is the reason to look at all of
+ * them at once, and it is what explains the fallback to the pole.
+ */
+export const originCandidatesOf = (
+ geometry: Geometry | null | undefined
+): Partial> => {
+ if (!geometry) return {};
+ const { rings, pole } = bestPartOf(geometry);
+ if (!rings || !pole) return {};
+
+ const candidates: Partial> = {
+ // polylabel proves this one by its own distance, so it is inside by
+ // construction
+ pole: { point: pole.point, inside: true },
+ };
+
+ const spine = spineOfPart(rings);
+ if (spine) candidates.spine = { point: spine, inside: isInsideRings(rings, spine) };
+
+ const centroid = centroidOfPart(rings);
+ if (centroid) {
+ candidates.centroid = {
+ point: centroid,
+ inside: isInsideRings(rings, centroid),
+ };
+ }
+
+ return candidates;
+};
+
+export const interiorPointOf = (
+ geometry: Geometry | null | undefined,
+ strategy: OriginStrategy = "pole"
+): [number, number] | undefined => {
+ if (!geometry) return undefined;
+
+ const { rings, pole } = bestPartOf(geometry);
+
+ if (rings && pole && strategy !== "pole") {
+ const candidate =
+ strategy === "centroid" ? centroidOfPart(rings) : spineOfPart(rings);
+ // both can land outside a bent shape, and an origin on or beyond the border
+ // is exactly what the pole is here to prevent
+ if (candidate && isInsideRings(rings, candidate)) return candidate;
+ }
+
+ // points, lines, and areas polylabel could not place: better on the feature
+ // than nowhere, since without an origin the arrow keys have nothing to do
+ return pole?.point ?? onFeaturePointOf(geometry);
+};
+
+/** Areas cast rays, everything else uses the cone. */
+export const isAreaGeometry = (type: string | undefined): boolean =>
+ type === "Polygon" || type === "MultiPolygon";
+
+/**
+ * Every ring, line or coordinate of a geometry as a flat list of parts, in the
+ * geometry's own coordinates. Rings stay closed, so ray casting sees a boundary
+ * rather than an open polyline.
+ */
+export const partsOfGeometry = (
+ geometry: Geometry | null | undefined
+): Position[][] => {
+ if (!geometry) return [];
+ switch (geometry.type) {
+ case "Point":
+ return [[geometry.coordinates]];
+ case "MultiPoint":
+ return geometry.coordinates.map((position) => [position]);
+ case "LineString":
+ return [geometry.coordinates];
+ case "MultiLineString":
+ case "Polygon":
+ return geometry.coordinates;
+ case "MultiPolygon":
+ return geometry.coordinates.flat();
+ case "GeometryCollection":
+ return geometry.geometries.flatMap(partsOfGeometry);
+ default:
+ return [];
+ }
+};
+
+/** Geographic bounding box `[west, south, east, north]` of a list of parts. */
+export const bboxOfParts = (
+ parts: Position[][]
+): [number, number, number, number] | undefined => {
+ let west = Infinity;
+ let south = Infinity;
+ let east = -Infinity;
+ let north = -Infinity;
+ for (const part of parts) {
+ for (const [lng, lat] of part) {
+ if (lng < west) west = lng;
+ if (lat < south) south = lat;
+ if (lng > east) east = lng;
+ if (lat > north) north = lat;
+ }
+ }
+ return west === Infinity ? undefined : [west, south, east, north];
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.spec.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.spec.ts
new file mode 100644
index 0000000000..e1a2ae50db
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.spec.ts
@@ -0,0 +1,273 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveNavConstants } from "./constants";
+import { rotate } from "./geometry";
+import { pickInDirection } from "./pick";
+import type { PickInput, ProjectedCandidate, ScreenPoint } from "./types";
+
+/**
+ * The picking core against fixed screen coordinates, with no map involved.
+ *
+ * Screen `y` grows downwards, so the axis used throughout is up = `(0, -1)`.
+ */
+
+const UP: ScreenPoint = { x: 0, y: -1 };
+const ORIGIN: ScreenPoint = { x: 0, y: 0 };
+
+/** A point candidate `distancePx` away from the origin, `angleDeg` off the axis. */
+const pointAt = (
+ key: string,
+ distancePx: number,
+ angleDeg: number,
+ layerId?: string
+): ProjectedCandidate => {
+ const direction = rotate(UP, angleDeg);
+ return {
+ key,
+ isArea: false,
+ parts: [[{ x: direction.x * distancePx, y: direction.y * distancePx }]],
+ ...(layerId ? { layerId } : {}),
+ };
+};
+
+const polygon = (
+ key: string,
+ ring: [number, number][]
+): ProjectedCandidate => ({
+ key,
+ isArea: true,
+ parts: [[...ring, ring[0]].map(([x, y]) => ({ x, y }))],
+});
+
+const inputFor = (
+ candidates: ProjectedCandidate[],
+ overrides: Partial = {}
+): PickInput => ({
+ origin: ORIGIN,
+ axis: UP,
+ candidates,
+ constants: resolveNavConstants(),
+ originIsArea: false,
+ strategy: "nearest-in-cone",
+ crossLayer: "free",
+ currentLayerBonus: 0.6,
+ centerRayBonus: 0.85,
+ minStepPx: 2,
+ fanDeg: 8,
+ rayLengthPx: 4000,
+ ...overrides,
+});
+
+describe("nearest-in-cone", () => {
+ it("lets a near off-axis candidate beat a distant on-axis one (A1)", () => {
+ const result = pickInDirection(
+ inputFor([
+ pointAt("far-on-axis", 500, 0),
+ pointAt("near-off-axis", 40, 15),
+ ])
+ );
+
+ expect(result.winnerKey).toBe("near-off-axis");
+ // 40 · (1 + 2.5 · 15/60) = 65 against 500
+ const near = result.explanation.evaluations.find(
+ (evaluation) => evaluation.key === "near-off-axis"
+ );
+ expect(near?.cost).toBeCloseTo(65, 6);
+ });
+
+ it("rejects a candidate that is essentially sideways (A2)", () => {
+ const result = pickInDirection(inputFor([pointAt("sideways", 30, 89)]));
+
+ expect(result.winnerKey).toBeUndefined();
+ expect(result.explanation.evaluations[0].rejectedBecause).toBe(
+ "outside-cone"
+ );
+ });
+
+ it("pins the 3.5x break-even at the cone edge (A3)", () => {
+ const loses = pickInDirection(
+ inputFor([pointAt("on-axis", 100, 0), pointAt("edge", 30, 60)])
+ );
+ // 30 · 3.5 = 105 > 100
+ expect(loses.winnerKey).toBe("on-axis");
+
+ const wins = pickInDirection(
+ inputFor([pointAt("on-axis", 100, 0), pointAt("edge", 25, 60)])
+ );
+ // 25 · 3.5 = 87.5 < 100
+ expect(wins.winnerKey).toBe("edge");
+ });
+
+ it("returns no winner when nothing lies in that direction (A8)", () => {
+ const result = pickInDirection(
+ inputFor([pointAt("behind", 40, 180), pointAt("sideways", 40, 95)])
+ );
+
+ expect(result.winnerKey).toBeUndefined();
+ expect(
+ result.explanation.evaluations.every(
+ (evaluation) => evaluation.rejectedBecause === "behind-origin"
+ )
+ ).toBe(true);
+ });
+
+ it("resolves identical costs through the full tie-break order (A7)", () => {
+ const tied = ["candidate-c", "candidate-a", "candidate-b"].map((key) =>
+ pointAt(key, 100, 0)
+ );
+
+ const winners = [
+ tied,
+ [tied[2], tied[0], tied[1]],
+ [tied[1], tied[2], tied[0]],
+ ].map((shuffled) => pickInDirection(inputFor(shuffled)).winnerKey);
+
+ expect(winners).toEqual(["candidate-a", "candidate-a", "candidate-a"]);
+ });
+
+ it("makes a layer change need a decisively closer candidate under prefer-current", () => {
+ const candidates = [
+ pointAt("same-layer", 100, 0, "parcels"),
+ pointAt("other-layer", 70, 0, "poi"),
+ ];
+ const overrides = {
+ crossLayer: "prefer-current" as const,
+ currentLayerId: "parcels",
+ };
+
+ // 100 · 0.6 = 60 beats 70
+ expect(pickInDirection(inputFor(candidates, overrides)).winnerKey).toBe(
+ "same-layer"
+ );
+
+ const decisive = [candidates[0], pointAt("other-layer", 50, 0, "poi")];
+ expect(pickInDirection(inputFor(decisive, overrides)).winnerKey).toBe(
+ "other-layer"
+ );
+ });
+
+ it("drops candidates outside the walked layer under locked", () => {
+ const result = pickInDirection(
+ inputFor([pointAt("other-layer", 40, 0, "poi")], {
+ crossLayer: "locked",
+ currentLayerId: "parcels",
+ })
+ );
+
+ expect(result.winnerKey).toBeUndefined();
+ expect(result.explanation.evaluations[0].rejectedBecause).toBe(
+ "out-of-scope"
+ );
+ });
+});
+
+describe("first-crossed on gap-free coverage (A5)", () => {
+ /**
+ * The origin parcel comes to a peak at the vertex `(0, 0)`, straight ahead of
+ * the origin point. `edge-sharing` lies behind the parcel's own left flank and
+ * therefore shares an edge with it; `diagonal` sits above the vertex and
+ * touches the origin parcel at that single point only.
+ *
+ * Both strategies are asserted, so the reason the second one exists cannot be
+ * optimised away later.
+ */
+ const origin: ScreenPoint = { x: -10, y: 140 };
+ const edgeSharing = polygon("edge-sharing", [
+ [-140, 60],
+ [0, 0],
+ [-60, -60],
+ ]);
+ const diagonal = polygon("diagonal", [
+ [0, 0],
+ [100, -80],
+ [-100, -80],
+ ]);
+ const candidates = [diagonal, edgeSharing];
+
+ it("selects the edge-sharing neighbour", () => {
+ const result = pickInDirection(
+ inputFor(candidates, {
+ origin,
+ originIsArea: true,
+ strategy: "first-crossed",
+ })
+ );
+
+ expect(result.explanation.strategyUsed).toBe("first-crossed");
+ expect(result.winnerKey).toBe("edge-sharing");
+ });
+
+ it("documents that the cone selects the diagonal one instead", () => {
+ const result = pickInDirection(
+ inputFor(candidates, { origin, strategy: "nearest-in-cone" })
+ );
+
+ expect(result.winnerKey).toBe("diagonal");
+ });
+
+ it("falls through to the cone when no ray crosses anything", () => {
+ const result = pickInDirection(
+ inputFor([pointAt("scattered-point", 80, 10)], {
+ origin: ORIGIN,
+ originIsArea: true,
+ strategy: "auto",
+ })
+ );
+
+ expect(result.explanation.strategyUsed).toBe("nearest-in-cone");
+ expect(result.explanation.rays).toHaveLength(3);
+ expect(result.winnerKey).toBe("scattered-point");
+ });
+});
+
+/**
+ * The pressed direction outranks the fan around it.
+ *
+ * Measured in the geoportal: a step to the left picked the parcel diagonally
+ * below, whose corner the lower fan ray clipped 132 px out, over the parcel
+ * lying straight ahead whose edge the centre ray met at 141 px. Ranking all
+ * three rays by raw distance makes the corner win; discounting the centre ray
+ * makes the obvious neighbour win while the corner case stays reachable.
+ */
+describe("centre ray preference in first-crossed", () => {
+ /** straight ahead, spanning the axis, its near edge 141 px up */
+ const ahead = polygon("ahead", [
+ [-40, -141],
+ [40, -141],
+ [40, -400],
+ [-40, -400],
+ ]);
+ /**
+ * Beside the axis, so the centre ray misses it entirely and only the fan
+ * reaches it, entering about 126 px out — nearer than `ahead`, which is what
+ * makes it win on raw distance.
+ */
+ const clipped = polygon("clipped", [
+ [-60, -125],
+ [-10, -125],
+ [-10, -300],
+ [-60, -300],
+ ]);
+
+ it("keeps the nearer fan crossing when the rays rank alike", () => {
+ const result = pickInDirection(
+ inputFor([ahead, clipped], {
+ strategy: "first-crossed",
+ originIsArea: true,
+ centerRayBonus: 1,
+ })
+ );
+ expect(result.winnerKey).toBe("clipped");
+ });
+
+ it("prefers the feature straight ahead once the centre ray is favoured", () => {
+ const result = pickInDirection(
+ inputFor([ahead, clipped], {
+ strategy: "first-crossed",
+ originIsArea: true,
+ centerRayBonus: 0.85,
+ })
+ );
+ expect(result.winnerKey).toBe("ahead");
+ });
+});
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.ts
new file mode 100644
index 0000000000..e8c27879eb
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.ts
@@ -0,0 +1,323 @@
+import {
+ angleBetweenDeg,
+ firstCrossing,
+ length,
+ nearestPointOfCandidate,
+ rotate,
+ subtract,
+} from "./geometry";
+import type {
+ CandidateEvaluation,
+ PickExplanation,
+ PickInput,
+ PickResult,
+ ProjectedCandidate,
+ ResolvedNavConstants,
+ ScreenPoint,
+} from "./types";
+
+/**
+ * The picking core: which feature lies in the pressed direction.
+ *
+ * Pure and map-free. It takes projected outlines in screen pixels and returns
+ * the winner together with the numbers that produced it, so the overlay draws
+ * exactly what was decided and the whole rule is testable without a map.
+ *
+ * Two strategies answer two different questions:
+ *
+ * `nearest-in-cone` ranks candidates by an *effective distance*
+ * `cost = d · (1 + w · (θ/θmax)^p)`. The penalty is multiplicative, not
+ * additive, so it never mixes pixels with degrees: scaling every distance
+ * scales every cost and leaves the ranking untouched, which is why one `w`
+ * holds at every zoom, on every screen and over every dataset. With `p = 1` the
+ * penalty reaches `1 + w` at the cone edge, so `w = 2.5` reads as "a feature
+ * inside the cone wins as soon as it is more than 3.5 times closer".
+ *
+ * `first-crossed` asks instead whose boundary a walk along the axis crosses
+ * first. Gap-free coverage needs it: where four parcels meet at one vertex, the
+ * diagonal parcel and the edge-sharing neighbour have their nearest point at
+ * that same vertex, so their `d` is identical and the diagonal one may even
+ * have the smaller `θ`. No choice of constants separates them — but a ray
+ * leaving through the shared edge enters the neighbour, and can only enter the
+ * diagonal parcel by passing exactly through the vertex.
+ */
+
+/** The effective distance a candidate is ranked by. */
+export const costOf = (
+ distancePx: number,
+ angleDeg: number,
+ { coneAngleDeg, angleWeight, anglePower }: ResolvedNavConstants
+): number =>
+ distancePx *
+ (1 + angleWeight * Math.pow(angleDeg / coneAngleDeg, anglePower));
+
+/**
+ * Total order over the survivors, so the same map state and key always produce
+ * the same result: iteration order over a hash map is not guaranteed, and two
+ * candidates can cost exactly the same. Lower cost, then smaller angle, then
+ * smaller distance, then the lexicographically smaller key.
+ */
+const byCost = (a: CandidateEvaluation, b: CandidateEvaluation): number =>
+ a.cost - b.cost ||
+ a.angleDeg - b.angleDeg ||
+ a.distancePx - b.distancePx ||
+ (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
+
+/** Candidates outside the walked layer are dropped only under "locked". */
+const isOutOfScope = (
+ candidate: ProjectedCandidate,
+ input: Pick
+): boolean =>
+ input.crossLayer === "locked" &&
+ input.currentLayerId !== undefined &&
+ candidate.layerId !== input.currentLayerId;
+
+const rejected = (
+ key: string,
+ point: ScreenPoint,
+ distancePx: number,
+ angleDeg: number,
+ because: CandidateEvaluation["rejectedBecause"]
+): CandidateEvaluation => ({
+ key,
+ nearestPointPx: point,
+ distancePx,
+ angleDeg,
+ cost: Infinity,
+ rejectedBecause: because,
+});
+
+/**
+ * The cone strategy. Hard gates first — a candidate outside the cone does not
+ * lie in that direction at all, and one closer than `minStepPx` is co-located
+ * with the origin and would trap the cursor — then the cost, then the
+ * cross-layer multiplier that makes a layer change need a decisively closer
+ * candidate.
+ */
+export const evaluateInCone = (input: PickInput): CandidateEvaluation[] => {
+ const { origin, axis, constants, minStepPx } = input;
+ const evaluations: CandidateEvaluation[] = [];
+
+ for (const candidate of input.candidates) {
+ const nearest = nearestPointOfCandidate(origin, candidate);
+ if (!nearest) continue;
+
+ const delta = subtract(nearest, origin);
+ const distancePx = length(delta);
+ const angleDeg = angleBetweenDeg(delta, axis);
+
+ if (isOutOfScope(candidate, input)) {
+ evaluations.push(
+ rejected(candidate.key, nearest, distancePx, angleDeg, "out-of-scope")
+ );
+ continue;
+ }
+ if (distancePx < minStepPx) {
+ evaluations.push(
+ rejected(candidate.key, nearest, distancePx, angleDeg, "too-close")
+ );
+ continue;
+ }
+ if (angleDeg > constants.coneAngleDeg) {
+ evaluations.push(
+ rejected(
+ candidate.key,
+ nearest,
+ distancePx,
+ angleDeg,
+ // past a right angle it is not merely off-axis, it is behind
+ angleDeg >= 90 ? "behind-origin" : "outside-cone"
+ )
+ );
+ continue;
+ }
+
+ const base = costOf(distancePx, angleDeg, constants);
+ const isCurrentLayer =
+ input.currentLayerId !== undefined &&
+ candidate.layerId === input.currentLayerId;
+ const cost =
+ input.crossLayer === "prefer-current" && isCurrentLayer
+ ? base * input.currentLayerBonus
+ : base;
+
+ evaluations.push({
+ key: candidate.key,
+ nearestPointPx: nearest,
+ distancePx,
+ angleDeg,
+ cost,
+ });
+ }
+
+ return evaluations;
+};
+
+const winnerOf = (evaluations: CandidateEvaluation[]): string | undefined => {
+ const survivors = evaluations
+ .filter((evaluation) => evaluation.rejectedBecause === undefined)
+ .sort(byCost);
+ return survivors[0]?.key;
+};
+
+export type FirstCrossedResult = {
+ evaluations: CandidateEvaluation[];
+ rays: NonNullable;
+ winnerKey?: string;
+};
+
+/**
+ * The ray strategy. Three rays at `−fan`, `0` and `+fan` degrees rather than
+ * one: a single ray can leave the origin exactly through the vertex four
+ * parcels share, or run exactly along a shared border, and both are ties the
+ * fan resolves. The overall smallest crossing wins, the centre ray taking ties.
+ *
+ * The cross-layer *multiplier* of the cone strategy has no counterpart here —
+ * there is no cost to multiply, only a crossing distance — but "locked" still
+ * drops candidates outside the walked layer, since that is a scope rule.
+ */
+export const evaluateFirstCrossed = (input: PickInput): FirstCrossedResult => {
+ const { origin, axis, fanDeg, rayLengthPx } = input;
+ const rayAngles = [0, -fanDeg, fanDeg];
+ const directions = rayAngles.map((angleDeg) => rotate(axis, angleDeg));
+
+ const evaluations: CandidateEvaluation[] = [];
+ const nearestPerRay: Array = rayAngles.map(
+ () => undefined
+ );
+ const nearestTPerRay: number[] = rayAngles.map(() => Infinity);
+
+ /**
+ * The pressed direction counts for more than the fan around it.
+ *
+ * The fan exists so that a neighbour meeting the origin at a corner is still
+ * reachable, but ranking all three rays by raw distance lets a parcel the
+ * outer ray only clips beat the one lying straight ahead. Discounting the
+ * centre ray means a fan crossing has to be that much closer to win, which
+ * leaves the corner case working while the obvious neighbour stays the
+ * obvious answer. A bonus of 1 is the old behaviour.
+ */
+ const centerRayBonus = input.centerRayBonus ?? 1;
+ const costOfCrossing = (t: number, rayIndex: number) =>
+ rayIndex === 0 ? t * centerRayBonus : t;
+
+ for (const candidate of input.candidates) {
+ if (isOutOfScope(candidate, input)) continue;
+
+ let best:
+ | { t: number; point: ScreenPoint; rayIndex: number; cost: number }
+ | undefined;
+ for (let rayIndex = 0; rayIndex < directions.length; rayIndex++) {
+ const crossing = firstCrossing(
+ origin,
+ directions[rayIndex],
+ candidate,
+ rayLengthPx
+ );
+ if (!crossing) continue;
+ if (crossing.t < nearestTPerRay[rayIndex]) {
+ nearestTPerRay[rayIndex] = crossing.t;
+ nearestPerRay[rayIndex] = crossing.point;
+ }
+ const cost = costOfCrossing(crossing.t, rayIndex);
+ // rayAngles starts with the centre ray, so a strict `<` lets it keep ties
+ if (!best || cost < best.cost) {
+ best = { ...crossing, rayIndex, cost };
+ }
+ }
+
+ if (!best || best.t < input.minStepPx) continue;
+
+ evaluations.push({
+ key: candidate.key,
+ nearestPointPx: best.point,
+ distancePx: best.t,
+ // the crossing lies on its ray, so the ray's own angle is its θ
+ angleDeg: Math.abs(rayAngles[best.rayIndex]),
+ cost: best.cost,
+ });
+ }
+
+ return {
+ evaluations,
+ rays: rayAngles.map((angleDeg, index) => ({
+ angleDeg,
+ ...(nearestPerRay[index] ? { crossingPx: nearestPerRay[index] } : {}),
+ })),
+ winnerKey: winnerOf(evaluations),
+ };
+};
+
+/**
+ * One keypress. `auto` casts rays from a polygon origin and uses the cone
+ * otherwise; when no ray crosses anything the cone still runs, within the same
+ * keypress, so a parcel at the edge of its coverage can still step onto a
+ * neighbouring point layer.
+ */
+export const pickInDirection = (input: PickInput): PickResult => {
+ const strategy =
+ input.strategy === "auto"
+ ? input.originIsArea
+ ? "first-crossed"
+ : "nearest-in-cone"
+ : input.strategy;
+
+ const base = {
+ originPx: input.origin,
+ axis: input.axis,
+ coneAngleDeg: input.constants.coneAngleDeg,
+ angleWeight: input.constants.angleWeight,
+ anglePower: input.constants.anglePower,
+ };
+
+ if (strategy === "first-crossed") {
+ const crossed = evaluateFirstCrossed(input);
+ if (crossed.winnerKey !== undefined) {
+ return {
+ winnerKey: crossed.winnerKey,
+ explanation: {
+ ...base,
+ strategyUsed: "first-crossed",
+ rays: crossed.rays,
+ evaluations: crossed.evaluations,
+ winnerKey: crossed.winnerKey,
+ },
+ };
+ }
+ const evaluations = evaluateInCone(input);
+ const winnerKey = winnerOf(evaluations);
+ return {
+ winnerKey,
+ explanation: {
+ ...base,
+ strategyUsed: "nearest-in-cone",
+ // kept: they are why the cone had to run at all
+ rays: crossed.rays,
+ evaluations,
+ ...(winnerKey === undefined ? {} : { winnerKey }),
+ },
+ };
+ }
+
+ const evaluations = evaluateInCone(input);
+ const winnerKey = winnerOf(evaluations);
+ return {
+ winnerKey,
+ explanation: {
+ ...base,
+ strategyUsed: "nearest-in-cone",
+ evaluations,
+ ...(winnerKey === undefined ? {} : { winnerKey }),
+ },
+ };
+};
+
+/**
+ * The ranked survivors, best first. The addon walks this list when
+ * `verifyWithRenderer` rejects the winner.
+ */
+export const rankedKeys = (explanation: PickExplanation): string[] =>
+ explanation.evaluations
+ .filter((evaluation) => evaluation.rejectedBecause === undefined)
+ .sort(byCost)
+ .map((evaluation) => evaluation.key);
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/polylabel.d.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/polylabel.d.ts
new file mode 100644
index 0000000000..4e4ceae420
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/polylabel.d.ts
@@ -0,0 +1,14 @@
+/**
+ * polylabel 2.x ships no declarations of its own.
+ *
+ * The default export returns the point as a two-element array that also carries
+ * `distance`: the signed distance from that point to the nearest polygon edge,
+ * positive inside and 0 on the degenerate early-exits.
+ */
+declare module "polylabel" {
+ export default function polylabel(
+ polygon: number[][][],
+ precision?: number,
+ debug?: boolean
+ ): number[] & { distance: number };
+}
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts
new file mode 100644
index 0000000000..3626c8d77f
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts
@@ -0,0 +1,89 @@
+import type { Map as MaplibreMap } from "maplibre-gl";
+
+import type { NavCandidate } from "./candidates";
+import { boxOfPoints, boxRejection } from "./geometry";
+import type { ProjectedCandidate, ScreenPoint } from "./types";
+
+/**
+ * Geographic candidates to screen outlines, with the cheap prune in between.
+ *
+ * Projection is the expensive part of a keypress, so a candidate's four
+ * bounding-box corners are projected first and the whole candidate dropped when
+ * that box cannot possibly satisfy the direction gates. Only the survivors have
+ * their full rings projected.
+ *
+ * The box is the axis-aligned hull of the projected corners, which under map
+ * rotation is larger than the true projected extent — larger is the safe side,
+ * since the prune must never drop a candidate that could have won.
+ */
+
+const toScreenPoint = (point: { x: number; y: number }): ScreenPoint => ({
+ x: point.x,
+ y: point.y,
+});
+
+export const projectCandidates = ({
+ map,
+ candidates,
+ origin,
+ axis,
+ coneAngleDeg,
+ excludeKey,
+ excludeKeys,
+}: {
+ map: MaplibreMap;
+ candidates: NavCandidate[];
+ origin: ScreenPoint;
+ axis: ScreenPoint;
+ coneAngleDeg: number;
+ /** the origin's own feature, which must not be a candidate for itself */
+ excludeKey?: string;
+ /**
+ * Anything else the caller wants kept out of this one pick, currently the
+ * feature the walk stood on one step ago. Two features sharing a long
+ * diagonal border each lie in the pressed direction from the other, so
+ * repeating the key bounces between them forever unless the step before is
+ * remembered.
+ */
+ excludeKeys?: ReadonlySet;
+}): ProjectedCandidate[] => {
+ const projected: ProjectedCandidate[] = [];
+
+ for (const candidate of candidates) {
+ if (candidate.key === excludeKey) continue;
+ if (excludeKeys?.has(candidate.key)) continue;
+
+ const [west, south, east, north] = candidate.bbox;
+ const box = boxOfPoints([
+ toScreenPoint(map.project([west, south])),
+ toScreenPoint(map.project([east, south])),
+ toScreenPoint(map.project([east, north])),
+ toScreenPoint(map.project([west, north])),
+ ]);
+ // the ray strategy needs the boundary a walk crosses, which for a candidate
+ // wrapping the origin lies in every direction; the cone rejects it on angle
+ // anyway, so the prune stays a prune and not a second gate
+ if (boxRejection(box, origin, axis, coneAngleDeg)) continue;
+
+ projected.push({
+ key: candidate.key,
+ ...(candidate.catalogLayerId
+ ? { layerId: candidate.catalogLayerId }
+ : {}),
+ isArea: candidate.isArea,
+ parts: candidate.parts.map((part) =>
+ part.map((position) =>
+ toScreenPoint(map.project([position[0], position[1]]))
+ )
+ ),
+ });
+ }
+
+ return projected;
+};
+
+/** Length the rays of `first-crossed` reach: the viewport's own diagonal. */
+export const viewportDiagonalPx = (map: MaplibreMap): number => {
+ const canvas = map.getCanvas();
+ return Math.hypot(canvas.clientWidth, canvas.clientHeight);
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/scope.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/scope.ts
new file mode 100644
index 0000000000..e10e00a672
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/scope.ts
@@ -0,0 +1,140 @@
+import type { Map as MaplibreMap, MapGeoJSONFeature } from "maplibre-gl";
+
+import { isLayerGroup, type LayerStackEntry } from "@carma-mapping/layers";
+
+import type { FeatureKeyboardNavConfig } from "./types";
+
+/**
+ * Scope resolution for the three deployment shapes.
+ *
+ * The addon is declared in three places and is scoped by where it is declared:
+ * on a route it navigates every navigable layer, on a workflow's `tools` the
+ * layers of the group that workflow creates, on a layer entry's `tools` that
+ * one catalog layer. Nothing downstream branches on the shape — the shapes only
+ * differ in what comes out of here.
+ *
+ * Which style layers belong to a catalog layer is read from the catalog
+ * metadata the style composer stamps, never guessed from layer id spelling.
+ */
+
+export type NavShape = "global" | "group" | "layer";
+
+export type NavScope = {
+ shape: NavShape;
+ /** style layer ids to query; `undefined` means every layer the style draws */
+ styleLayerIds?: string[];
+ /** catalog layer ids in scope; `undefined` means unrestricted */
+ catalogLayerIds?: string[];
+ /**
+ * Only features drawn by a catalog layer are navigable.
+ *
+ * This is what "every *navigable* layer" means in the global shape: the
+ * basemap is a vector source like any other, and without this the arrow keys
+ * would step onto road segments and label anchors. A config that names its
+ * own `layers` has already said what it wants and is not narrowed further.
+ */
+ requireCatalogLayer?: boolean;
+};
+
+/**
+ * The catalog layer a style layer or feature belongs to. Primary source is the
+ * `metadata["layer-id"]` stamp; the `"::"` namespacing
+ * of imperative mode is the fallback for layers added outside the composer.
+ */
+const catalogIdOfStyleLayer = (layer: {
+ id: string;
+ metadata?: unknown;
+}): string | undefined => {
+ const metadata = layer.metadata as Record | undefined;
+ const stamped = metadata?.["layer-id"];
+ if (typeof stamped === "string" && stamped) return stamped;
+ return layer.id.includes("::") ? layer.id.split("::")[0] : undefined;
+};
+
+export const catalogLayerIdOfFeature = (
+ feature: MapGeoJSONFeature
+): string | undefined =>
+ feature.layer ? catalogIdOfStyleLayer(feature.layer) : undefined;
+
+/** The catalog layer ids a target covers: a group resolves to its members. */
+export const catalogLayerIdsOfTarget = (target: LayerStackEntry): string[] =>
+ isLayerGroup(target) ? target.layers.map((layer) => layer.id) : [target.id];
+
+/**
+ * Style layer ids matching the `layers` patterns of the global shape. Matched
+ * against the style's own ids and, in imperative mode, against the
+ * merged-mode keys the map exposes, so a config written once works in both.
+ */
+const styleLayersMatchingPatterns = (
+ map: MaplibreMap,
+ patterns: string[]
+): string[] => {
+ const regexes = patterns.map((pattern) => new RegExp(pattern));
+ const ids = (map.getStyle()?.layers ?? [])
+ .filter((layer) => regexes.some((regex) => regex.test(layer.id)))
+ .map((layer) => layer.id);
+
+ const layerIdMap = (map as unknown as Record)
+ .__carmaLayerIdMap as
+ | { mergedToNamespaced: Map }
+ | undefined;
+ if (layerIdMap?.mergedToNamespaced) {
+ for (const [mergedKey, namespacedId] of layerIdMap.mergedToNamespaced) {
+ if (
+ regexes.some((regex) => regex.test(mergedKey)) &&
+ !ids.includes(namespacedId)
+ ) {
+ ids.push(namespacedId);
+ }
+ }
+ }
+
+ return ids;
+};
+
+export const resolveNavScope = (
+ map: MaplibreMap | null,
+ target: LayerStackEntry | null,
+ config: FeatureKeyboardNavConfig
+): NavScope => {
+ if (!map) {
+ return {
+ shape: target ? (isLayerGroup(target) ? "group" : "layer") : "global",
+ };
+ }
+
+ if (target) {
+ const catalogLayerIds = catalogLayerIdsOfTarget(target);
+ const wanted = new Set(catalogLayerIds);
+ const styleLayerIds = (map.getStyle()?.layers ?? [])
+ .filter((layer) => {
+ const catalogId = catalogIdOfStyleLayer(layer);
+ return catalogId !== undefined && wanted.has(catalogId);
+ })
+ .map((layer) => layer.id);
+ return {
+ shape: isLayerGroup(target) ? "group" : "layer",
+ styleLayerIds,
+ catalogLayerIds,
+ };
+ }
+
+ // global: the whole map, optionally narrowed by the config's own patterns
+ const patterns = config.layers ?? [];
+ if (patterns.length > 0) {
+ return {
+ shape: "global",
+ styleLayerIds: styleLayersMatchingPatterns(map, patterns),
+ };
+ }
+ return { shape: "global", requireCatalogLayer: true };
+};
+
+/** A stable string for effect dependencies; scopes are rebuilt per render. */
+export const navScopeKey = (scope: NavScope): string =>
+ [
+ scope.shape,
+ scope.styleLayerIds?.join(",") ?? "*",
+ scope.catalogLayerIds?.join(",") ?? "*",
+ scope.requireCatalogLayer ? "catalog" : "any",
+ ].join("|");
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts
new file mode 100644
index 0000000000..c6e92651a4
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts
@@ -0,0 +1,241 @@
+import type { Positions } from "@carma-mapping/map-controls-layout";
+
+import type { OriginStrategy } from "./origin";
+
+/**
+ * Types of the keyboard navigation addon.
+ *
+ * Split off from the component so the picking core stays importable without
+ * React or MapLibre: everything here is data.
+ */
+
+/** A point in screen pixels. `y` grows downwards, as on the canvas. */
+export type ScreenPoint = { x: number; y: number };
+
+/** The four screen directions the arrow keys map to. */
+export type NavDirection = "up" | "down" | "left" | "right";
+
+/** Unit axis per direction. Screen `y` grows downwards, so up is `(0, -1)`. */
+export const NAV_AXES: Readonly> = {
+ up: { x: 0, y: -1 },
+ down: { x: 0, y: 1 },
+ left: { x: -1, y: 0 },
+ right: { x: 1, y: 0 },
+};
+
+export type NavStrategy = "auto" | "first-crossed" | "nearest-in-cone";
+export type NavStrategyUsed = "first-crossed" | "nearest-in-cone";
+export type NavCrossLayer = "prefer-current" | "free" | "locked";
+export type NavEdgeBehavior = "pan" | "stop";
+export type NavExplainMode = "off" | "brief" | "hold";
+
+/**
+ * One config object for all three deployment shapes (global addon, workflow
+ * tool, layer tool). Nothing here is shape-specific except `layers` and the
+ * control fields, which the tool shapes ignore because their target already
+ * defines the scope and their button already exists.
+ */
+export type FeatureKeyboardNavConfig = {
+ /** Style layer patterns to navigate. Only honoured in the global shape; ignored when a target defines the scope. */
+ layers?: string[];
+
+ /** How strictly navigation follows the axis, 0 fuzzy to 1 strict. Derives coneAngleDeg and angleWeight. Default: 0.5 */
+ sharpness?: number;
+ /** Half angle of the acceptance cone in degrees, the θmax of the spec. Overrides sharpness. Default: derived, 60 */
+ coneAngleDeg?: number;
+ /** Off-axis penalty w. A candidate inside the cone wins once it is more than (1 + w) times closer. Overrides sharpness. Default: derived, 2.5 */
+ angleWeight?: number;
+ /** Exponent p on the normalised angle. 2 makes small deviations nearly free. Not driven by sharpness. Default: 1 */
+ anglePower?: number;
+
+ /** Picking strategy. "auto" uses first-crossed for polygon origins and the cone otherwise. Default: "auto" */
+ strategy?: NavStrategy;
+ /** Half angle of the three-ray fan for first-crossed, in degrees. Default: 8 */
+ fanDeg?: number;
+ /** Candidates nearer than this are ignored, so co-located features cannot trap the cursor. Default: 2 */
+ minStepPx?: number;
+
+ /** What happens when another layer in scope offers a better candidate. Default: "prefer-current" */
+ crossLayer?: NavCrossLayer;
+ /** Cost multiplier for candidates in the current layer under "prefer-current". Default: 0.6 */
+ currentLayerBonus?: number;
+ /**
+ * Cost multiplier for a `first-crossed` hit on the ray of the pressed
+ * direction, against the two fan rays beside it.
+ *
+ * The fan is there so a neighbour that meets the origin at a corner is still
+ * reachable, but ranking all three rays by raw distance lets a parcel the
+ * outer ray merely clips beat the one lying straight ahead. Below 1 the fan
+ * has to be that much closer to win: at 0.85 a fan crossing needs to be about
+ * 15% nearer. 1 ranks all three alike, which is how this behaved before.
+ * Default: 0.85
+ */
+ centerRayBonus?: number;
+
+ /** Confirm the winner against what is actually drawn. Default: false */
+ verifyWithRenderer?: boolean;
+ /** Next-best retries when that confirmation fails. Default: 3 */
+ verifyMaxRetries?: number;
+
+ /** Behaviour when nothing lies in the pressed direction. Default: "pan" */
+ edgeBehavior?: NavEdgeBehavior;
+ /** Share of the viewport panned per edge step. Default: 0.5 */
+ panStepFraction?: number;
+ /** Duration of keep-in-view and edge pans, in ms. Default: 300 */
+ panDurationMs?: number;
+
+ /**
+ * Helper geometry overlay. Default: "brief"
+ *
+ * The starting value, not a fixed one: with anything but "off" the readout
+ * carries a switch that steps the picture through "brief", "hold" and "off"
+ * while the mode runs. "off" here keeps the readout and the switch away
+ * altogether.
+ */
+ explain?: NavExplainMode;
+ /** Fade delay for "brief", in ms. Default: 1200 */
+ explainMs?: number;
+ /**
+ * Which point inside a feature a step measures from.
+ *
+ * - `pole` (default): furthest from any edge. The only one that is guaranteed
+ * to lie inside, and arbitrary on a corridor, where the widest spot is
+ * wherever a junction bulges rather than anywhere meaningful.
+ * - `spine`: the middle along the shape, so half way down a street.
+ * - `centroid`: the area centroid, which is right for compact shapes.
+ *
+ * `spine` and `centroid` fall back to `pole` whenever their point lands
+ * outside the feature, which a bent shape will do. No automatic choice yet:
+ * set one, look at real parcels, then decide.
+ */
+ originStrategy?: OriginStrategy;
+ /**
+ * Whether walking into a feature moves its origin.
+ *
+ * - `dynamic` (default): a feature walked into measures from where the walk
+ * arrived, the middle of the stretch the step's ray spent inside it, drawn
+ * in red. Entering a long street from the south then continues from there
+ * rather than from a point far down the road.
+ * - `static`: always the point `originStrategy` computes, whether the feature
+ * was walked into or clicked.
+ */
+ originMode?: "dynamic" | "static";
+ /**
+ * Enter the mode as soon as the addon mounts, instead of waiting for the
+ * control to be switched on. Only the global shape has a control to switch,
+ * so this does nothing for the tool shapes, which are active while mounted.
+ * Default: false
+ */
+ startActive?: boolean;
+ /**
+ * Mark the interior point of the selected feature with a blue dot: the point
+ * navigation measures from, which is what makes a surprising step readable.
+ * Under `first-crossed` a parcel whose interior point falls inside the
+ * building standing on it will cross that building's wall before its own
+ * border, and the dot is where that becomes visible.
+ *
+ * Shown for the selected feature only, from a click as well as from an arrow
+ * key, and only while something is selected. One dot per visible shape was
+ * what this used to draw, and a viewport of ALKIS parcels then re-projected
+ * thousands of them on every map frame. Needs `explain` on. Default: false
+ */
+ showOrigins?: boolean;
+ /**
+ * Fill colour of that dot, as any CSS colour. Defaults to the blue the
+ * selection itself is drawn in, since it marks the selection. Default:
+ * "#1677ff"
+ */
+ originDotColor?: string;
+ /** Opacity of the dot layer, 0 to 1. Default: 1 */
+ originDotOpacity?: number;
+
+ /** Enter navigation mode as soon as a feature is selected. Default: false */
+ autoActivateOnSelect?: boolean;
+ /** Render the mode toggle in the control column. Only relevant in the global shape. Default: true */
+ showControl?: boolean;
+ /** Corner and sort order of that toggle. */
+ controlPosition?: Positions;
+ controlOrder?: number;
+
+ /** Upper bound on the candidate set; hitting it raises the degraded state. Default: 4000 */
+ maxCandidates?: number;
+ /** Debounce after the map settles before the candidate set is rebuilt, in ms. Default: 200 */
+ candidateDebounceMs?: number;
+};
+
+/** Why a candidate never reached the cost comparison. */
+export type CandidateRejection =
+ | "outside-cone"
+ | "behind-origin"
+ | "too-close"
+ | "out-of-scope";
+
+export type CandidateEvaluation = {
+ key: string;
+ nearestPointPx: ScreenPoint;
+ distancePx: number;
+ angleDeg: number;
+ cost: number;
+ rejectedBecause?: CandidateRejection;
+};
+
+export type PickExplanation = {
+ originPx: ScreenPoint;
+ axis: ScreenPoint;
+ strategyUsed: NavStrategyUsed;
+ /** the resolved constants, so the overlay can show what was actually in force */
+ coneAngleDeg: number;
+ angleWeight: number;
+ anglePower: number;
+ rays?: Array<{ angleDeg: number; crossingPx?: ScreenPoint }>;
+ evaluations: CandidateEvaluation[];
+ winnerKey?: string;
+};
+
+/**
+ * A candidate as the picking core sees it: an outline in screen pixels and the
+ * two labels the policies need. No feature, no map, no geographic coordinates —
+ * that is what makes the core testable with fixed numbers.
+ */
+export type ProjectedCandidate = {
+ key: string;
+ /** catalog layer the feature belongs to, for the cross-layer policy */
+ layerId?: string;
+ /** rings are closed and enclose an area; lines and points are open outlines */
+ isArea: boolean;
+ /** every ring, line or point of the feature, in screen pixels */
+ parts: ScreenPoint[][];
+};
+
+/** The three constants of the cost function, after `sharpness` and overrides. */
+export type ResolvedNavConstants = {
+ coneAngleDeg: number;
+ angleWeight: number;
+ anglePower: number;
+};
+
+export type PickInput = {
+ origin: ScreenPoint;
+ /** unit vector of the pressed direction, in screen space */
+ axis: ScreenPoint;
+ candidates: ProjectedCandidate[];
+ constants: ResolvedNavConstants;
+ /** the origin feature's own geometry is an area, which selects `first-crossed` under "auto" */
+ originIsArea: boolean;
+ /** catalog layer of the origin feature, for the cross-layer policy */
+ currentLayerId?: string;
+ strategy: NavStrategy;
+ crossLayer: NavCrossLayer;
+ currentLayerBonus: number;
+ /** cost multiplier for the centre ray of `first-crossed`; 1 ranks all alike */
+ centerRayBonus: number;
+ minStepPx: number;
+ fanDeg: number;
+ /** how far the rays of `first-crossed` reach; the viewport diagonal in practice */
+ rayLengthPx: number;
+};
+
+export type PickResult = {
+ winnerKey?: string;
+ explanation: PickExplanation;
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts
new file mode 100644
index 0000000000..b3f3f11335
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts
@@ -0,0 +1,192 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { Map as MaplibreMap, MapGeoJSONFeature } from "maplibre-gl";
+
+import {
+ buildCandidates,
+ EMPTY_CANDIDATE_SET,
+ type CandidateSet,
+} from "./candidates";
+import type { NavScope } from "./scope";
+import { navScopeKey } from "./scope";
+
+/**
+ * The candidate set, rebuilt once per settled map movement and never per
+ * keypress.
+ *
+ * `idle` rather than `moveend`: `moveend` fires before tile processing is
+ * finished, which is when `queryRenderedFeatures` throws "feature index out of
+ * bounds". A throw is not fatal here — the previous set stays in place and the
+ * degraded flag goes up, so navigation keeps working on a set the user has been
+ * told is incomplete instead of dying on a key.
+ *
+ * Consequences the addon documents to its users: the set describes data, not
+ * pixels. An icon the renderer dropped for a label collision stays navigable,
+ * and so does a polygon another layer covers completely; `verifyWithRenderer`
+ * buys that back where it matters. Features hidden by zoom range or a style
+ * filter are absent, which is intended — navigation follows what the style
+ * draws.
+ */
+
+export type NavCandidateState = {
+ candidateSet: CandidateSet;
+ /** bumped on every settled query, so a pan can be waited out */
+ version: number;
+};
+
+/**
+ * What the candidate set depends on, as a string.
+ *
+ * `idle` is a firehose: it fires after tile loads, after fades, and after every
+ * `keepInView` pan, so a held arrow key would otherwise re-query the whole
+ * viewport between steps, and on a dense ALKIS view one query walks thousands
+ * of rendered features. The centre is rounded to about a metre, which is finer
+ * than any move a user can make without changing what is on screen.
+ */
+const viewSignature = (map: MaplibreMap, scopeKey: string): string => {
+ const { lng, lat } = map.getCenter();
+ return [
+ scopeKey,
+ lng.toFixed(5),
+ lat.toFixed(5),
+ map.getZoom().toFixed(3),
+ map.getBearing().toFixed(1),
+ map.getPitch().toFixed(1),
+ // a view that has not moved still gains features while its tiles arrive,
+ // so the last idle of a load is a different signature from the ones before
+ map.areTilesLoaded() ? "loaded" : "loading",
+ ].join("|");
+};
+
+export const useNavCandidates = ({
+ map,
+ scope,
+ enabled,
+ maxCandidates,
+ debounceMs,
+}: {
+ map: MaplibreMap | null;
+ scope: NavScope;
+ enabled: boolean;
+ maxCandidates: number;
+ debounceMs: number;
+}): NavCandidateState => {
+ const [state, setState] = useState({
+ candidateSet: EMPTY_CANDIDATE_SET,
+ version: 0,
+ });
+
+ // scopes are rebuilt every render; the key is what actually changed
+ const scopeKey = navScopeKey(scope);
+ const scopeRef = useRef(scope);
+ scopeRef.current = scope;
+ const maxCandidatesRef = useRef(maxCandidates);
+ maxCandidatesRef.current = maxCandidates;
+ /** the view the current set was built for; `undefined` forces a rebuild */
+ const signatureRef = useRef(undefined);
+ /** how many candidates that set holds, so an empty one keeps retrying */
+ const lastCountRef = useRef(0);
+
+ const query = useCallback((mapInstance: MaplibreMap) => {
+ const { styleLayerIds, catalogLayerIds, requireCatalogLayer } =
+ scopeRef.current;
+
+ // a scoped navigation whose layers are not in the style right now has an
+ // empty set, which is different from an unscoped query of everything
+ let layers: string[] | undefined;
+ if (styleLayerIds) {
+ layers = styleLayerIds.filter((id) => {
+ try {
+ return Boolean(mapInstance.getLayer(id));
+ } catch {
+ return false;
+ }
+ });
+ if (layers.length === 0) {
+ setState((previous) => ({
+ candidateSet: EMPTY_CANDIDATE_SET,
+ version: previous.version + 1,
+ }));
+ return;
+ }
+ }
+
+ const signature = viewSignature(mapInstance, navScopeKey(scopeRef.current));
+ // an empty set is never a settled answer: the first query of a fresh map
+ // runs before the renderer has anything to report, and the view it recorded
+ // does not change afterwards, so the guard would keep the set empty until
+ // the user happened to pan
+ if (signature === signatureRef.current && lastCountRef.current > 0) return;
+ signatureRef.current = signature;
+
+ let features: MapGeoJSONFeature[];
+ try {
+ features = mapInstance.queryRenderedFeatures(
+ layers ? { layers } : undefined
+ );
+ } catch (error) {
+ console.warn(
+ "[FEATURE_KEYBOARD_NAV] candidate query failed, keeping the previous set",
+ error
+ );
+ setState((previous) => ({
+ candidateSet: { ...previous.candidateSet, degraded: true },
+ version: previous.version + 1,
+ }));
+ // a failed query says nothing about the view, so the next idle retries
+ signatureRef.current = undefined;
+ return;
+ }
+
+ const candidateSet = buildCandidates(features, {
+ catalogLayerIds,
+ requireCatalogLayer,
+ maxCandidates: maxCandidatesRef.current,
+ });
+ lastCountRef.current = candidateSet.candidates.length;
+ setState((previous) => ({
+ candidateSet,
+ version: previous.version + 1,
+ }));
+ }, []);
+
+ useEffect(() => {
+ if (!map || !enabled) {
+ return;
+ }
+
+ let timer: ReturnType | null = null;
+ const schedule = () => {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = null;
+ query(map);
+ }, debounceMs);
+ };
+
+ schedule();
+ map.on("idle", schedule);
+ return () => {
+ if (timer) clearTimeout(timer);
+ map.off("idle", schedule);
+ };
+ // `scopeKey` stands for the scope object, which is rebuilt per render
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [map, enabled, debounceMs, scopeKey, query]);
+
+ // leaving the mode drops the set, so re-entering never navigates on a
+ // viewport the user has since moved away from
+ useEffect(() => {
+ if (enabled) return;
+ // the set is dropped, so the next activation has to query again even if the
+ // map never moved in between
+ signatureRef.current = undefined;
+ lastCountRef.current = 0;
+ setState((previous) =>
+ previous.candidateSet === EMPTY_CANDIDATE_SET && previous.version === 0
+ ? previous
+ : { candidateSet: EMPTY_CANDIDATE_SET, version: 0 }
+ );
+ }, [enabled]);
+
+ return state;
+};
diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavScope.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavScope.ts
new file mode 100644
index 0000000000..81c4e64806
--- /dev/null
+++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavScope.ts
@@ -0,0 +1,48 @@
+import { useEffect, useState } from "react";
+import type { Map as MaplibreMap } from "maplibre-gl";
+
+import type { LayerStackEntry } from "@carma-mapping/layers";
+
+import { navScopeKey, resolveNavScope, type NavScope } from "./scope";
+
+/**
+ * The active scope, kept in step with the style.
+ *
+ * Which style layers belong to a catalog layer can only be read off the style,
+ * and the style is not finished when the addon mounts: layers arrive, a route
+ * switch rebuilds them, a workflow adds a group. Resolving once would leave a
+ * tool shape navigating an empty set for exactly as long as its layers took to
+ * load.
+ *
+ * `styledata` fires many times while tiles settle, so the resolved scope is
+ * compared by its key and the state is only replaced when it really changed;
+ * an unchanged scope returns the previous object and React bails out.
+ */
+export const useNavScope = (
+ map: MaplibreMap | null,
+ target: LayerStackEntry | null,
+ layerPatterns: string[]
+): NavScope => {
+ const patternKey = layerPatterns.join("|");
+ const [scope, setScope] = useState(() =>
+ resolveNavScope(map, target, { layers: layerPatterns })
+ );
+
+ useEffect(() => {
+ const patterns = patternKey ? patternKey.split("|") : [];
+ const update = () => {
+ const next = resolveNavScope(map, target, { layers: patterns });
+ setScope((previous) =>
+ navScopeKey(previous) === navScopeKey(next) ? previous : next
+ );
+ };
+ update();
+ if (!map) return;
+ map.on("styledata", update);
+ return () => {
+ map.off("styledata", update);
+ };
+ }, [map, target, patternKey]);
+
+ return scope;
+};
diff --git a/libraries/mapping/addons/src/index.ts b/libraries/mapping/addons/src/index.ts
index 9cfde4d9ee..09e16555ea 100644
--- a/libraries/mapping/addons/src/index.ts
+++ b/libraries/mapping/addons/src/index.ts
@@ -35,6 +35,34 @@ export {
CameraRestriction,
type CameraRestrictionConfig,
} from "./addons/CameraRestriction";
+export {
+ FeatureKeyboardNav,
+ featureKeyboardNavTrigger,
+ type FeatureNavigationModeState,
+} from "./addons/FeatureKeyboardNav";
+export {
+ NAV_AXES,
+ type CandidateEvaluation,
+ type FeatureKeyboardNavConfig,
+ type PickExplanation,
+ type ProjectedCandidate,
+ type ResolvedNavConstants,
+} from "./addons/feature-keyboard-nav/types";
+export {
+ constantsForSharpness,
+ resolveNavConstants,
+} from "./addons/feature-keyboard-nav/constants";
+export {
+ costOf,
+ pickInDirection,
+ rankedKeys,
+} from "./addons/feature-keyboard-nav/pick";
+export {
+ NAV_KEYMAP,
+ navHintRows,
+ resolveNavBinding,
+} from "./addons/feature-keyboard-nav/keymap";
+
export { GazetteerMode } from "./addons/GazetteerMode";
export { GazetteerSource } from "./addons/GazetteerSource";
export { VectorHighlight } from "./addons/VectorHighlight";
diff --git a/libraries/mapping/addons/src/lib/registry.ts b/libraries/mapping/addons/src/lib/registry.ts
index 105ebacc89..2b377364fc 100644
--- a/libraries/mapping/addons/src/lib/registry.ts
+++ b/libraries/mapping/addons/src/lib/registry.ts
@@ -16,6 +16,12 @@ import {
CameraRestriction,
type CameraRestrictionConfig,
} from "../addons/CameraRestriction";
+import {
+ FeatureKeyboardNav,
+ featureKeyboardNavTrigger,
+ type FeatureNavigationModeState,
+} from "../addons/FeatureKeyboardNav";
+import type { FeatureKeyboardNavConfig } from "../addons/feature-keyboard-nav/types";
import { GazetteerMode } from "../addons/GazetteerMode";
import { GazetteerSource } from "../addons/GazetteerSource";
import {
@@ -59,6 +65,7 @@ export type AddonConfigMap = {
visibleFeatureStatsSource: VisibleFeatureStatsSourceConfig;
visibleFeatureStatsPanel: VisibleFeatureStatsPanelConfig;
zoomToExtent: ZoomToExtentConfig;
+ featureKeyboardNav: FeatureKeyboardNavConfig;
};
export type AddonKind = keyof AddonConfigMap;
@@ -73,6 +80,11 @@ export type AddonStateMap = {
visibleFeatureStats: VisibleFeatureStatsState;
/** whether the highlighting mode is running; see `VectorHighlight` */
highlightMode: HighlightModeState;
+ /**
+ * whether arrow-key navigation is running and whether its candidate set is
+ * complete; see `FeatureKeyboardNav`
+ */
+ featureNavigationMode: FeatureNavigationModeState;
/**
* image the info box shows instead of the feature photo at the current zoom;
* see `InfoBoxZoomImage`. Consumed by the host app's info box, not by an addon.
@@ -202,6 +214,14 @@ export const addonRegistry: {
requires: ["visibleFeatureStats"],
},
zoomToExtent: { trigger: zoomToExtentTrigger },
+ // one kind, three deployment shapes: `AddonHost` mounts it from a route's
+ // addon list and it draws its own control, while the trigger puts it on a
+ // workflow group's or a single layer's button
+ featureKeyboardNav: {
+ Component: FeatureKeyboardNav,
+ trigger: featureKeyboardNavTrigger,
+ provides: ["featureNavigationMode"],
+ },
};
const isKnownKind = (kind: string): kind is AddonKind =>
diff --git a/libraries/mapping/addons/vite.config.ts b/libraries/mapping/addons/vite.config.ts
new file mode 100644
index 0000000000..740dd88535
--- /dev/null
+++ b/libraries/mapping/addons/vite.config.ts
@@ -0,0 +1,22 @@
+///
+import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
+import { defineConfig } from "vite";
+
+// The library has no build step — it is consumed through its path alias. This
+// config exists for the unit tests of the picking core, which run without a map.
+export default defineConfig({
+ root: __dirname,
+ cacheDir: "../../../node_modules/.vite/libraries/mapping/addons",
+ plugins: [nxViteTsPaths()],
+ test: {
+ watch: false,
+ globals: true,
+ environment: "jsdom",
+ include: ["src/**/*.spec.{ts,tsx}"],
+ reporters: ["default"],
+ coverage: {
+ reportsDirectory: "../../../coverage/libraries/mapping/addons",
+ provider: "v8",
+ },
+ },
+});
diff --git a/libraries/mapping/map-controls-layout/src/lib/components/control-styles.ts b/libraries/mapping/map-controls-layout/src/lib/components/control-styles.ts
index c511a4f293..56e52a358b 100644
--- a/libraries/mapping/map-controls-layout/src/lib/components/control-styles.ts
+++ b/libraries/mapping/map-controls-layout/src/lib/components/control-styles.ts
@@ -191,9 +191,26 @@ const controlRendererStyles = {
...bottomControlGroupStyle,
alignItems: "flex-end",
},
+ /**
+ * Centred on the map, not centred among its siblings.
+ *
+ * As a flex child of the bottom row it moved whenever a neighbour appeared or
+ * grew — the infobox showing up in the bottom right slid the "centre" to the
+ * left. Taking it out of the flow keeps it where the name promises, and the
+ * left and right groups lay out as if it were not there.
+ */
bottomCenter: {
...bottomControlGroupStyle,
alignItems: "center",
+ // the group is as tall as the bottom row (`height: 100%`), and the row
+ // grows with the tallest thing in it — the infobox. Without this the
+ // content sits at the top of that stretched box and rides up as the infobox
+ // appears; anchored to the end it stays on the bottom edge where it belongs
+ justifyContent: "flex-end",
+ position: "absolute",
+ bottom: 0,
+ left: "50%",
+ transform: "translateX(-50%)",
},
bottomRight: {
...bottomControlGroupStyle,
diff --git a/libraries/mapping/map-controls-layout/src/lib/map-control.module.css b/libraries/mapping/map-controls-layout/src/lib/map-control.module.css
index 93ca01106a..6ac9737e98 100644
--- a/libraries/mapping/map-controls-layout/src/lib/map-control.module.css
+++ b/libraries/mapping/map-controls-layout/src/lib/map-control.module.css
@@ -89,9 +89,12 @@
margin-right: auto;
}
+/* centred on the map: without the shift, `left: 50%` puts the panel's left
+ edge at the centre rather than the panel itself */
.bottomcenter {
position: absolute;
left: 50%;
+ transform: translateX(-50%);
bottom: 0;
display: flex;
flex-direction: column;