From d39b60d55fb606318a175d2e8fc3be8409ab4dbe Mon Sep 17 00:00:00 2001 From: PavelOlkhovoi Date: Tue, 11 Aug 2026 14:09:10 +0200 Subject: [PATCH 01/20] #749 add feature keyboard nav addon --- .../app/components/layers/InteractionView.tsx | 5 + .../src/app/constants/fachzwillinge/boden.ts | 15 + .../app/constants/fachzwillinge/gesundheit.ts | 15 +- libraries/mapping/addons/README.md | 11 + libraries/mapping/addons/project.json | 7 + .../addons/src/addons/FeatureKeyboardNav.tsx | 679 ++++++++++++++++++ .../feature-keyboard-nav/ExplainOverlay.tsx | 322 +++++++++ .../addons/feature-keyboard-nav/candidates.ts | 132 ++++ .../feature-keyboard-nav/constants.spec.ts | 58 ++ .../addons/feature-keyboard-nav/constants.ts | 103 +++ .../addons/feature-keyboard-nav/geometry.ts | 222 ++++++ .../src/addons/feature-keyboard-nav/keymap.ts | 150 ++++ .../feature-keyboard-nav/origin.spec.ts | 97 +++ .../src/addons/feature-keyboard-nav/origin.ts | 79 ++ .../addons/feature-keyboard-nav/pick.spec.ts | 220 ++++++ .../src/addons/feature-keyboard-nav/pick.ts | 306 ++++++++ .../addons/feature-keyboard-nav/projection.ts | 79 ++ .../src/addons/feature-keyboard-nav/scope.ts | 140 ++++ .../src/addons/feature-keyboard-nav/types.ts | 165 +++++ .../feature-keyboard-nav/useNavCandidates.ts | 149 ++++ .../feature-keyboard-nav/useNavScope.ts | 48 ++ libraries/mapping/addons/src/index.ts | 28 + libraries/mapping/addons/src/lib/registry.ts | 20 + libraries/mapping/addons/vite.config.ts | 22 + 24 files changed, 3071 insertions(+), 1 deletion(-) create mode 100644 libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.spec.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/keymap.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.spec.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/scope.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavScope.ts create mode 100644 libraries/mapping/addons/vite.config.ts 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..1f08b95ef6 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts @@ -52,6 +52,21 @@ 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: "brief", + }, + }, { 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/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..7279d4353a --- /dev/null +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -0,0 +1,679 @@ +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_EDGE_BEHAVIOR, + DEFAULT_EXPLAIN, + DEFAULT_EXPLAIN_MS, + DEFAULT_FAN_DEG, + DEFAULT_MAX_CANDIDATES, + DEFAULT_MIN_STEP_PX, + DEFAULT_PAN_DURATION_MS, + DEFAULT_PAN_STEP_FRACTION, + DEFAULT_STRATEGY, + DEFAULT_VERIFY_MAX_RETRIES, + EDGE_PAN_SETTLE_TIMEOUT_MS, + KEEP_IN_VIEW_INSET_FRACTION, + resolveNavConstants, + SHIFT_PAN_PX, +} from "./feature-keyboard-nav/constants"; +import { + ExplainOverlay, + toExplainSnapshot, + type ExplainSnapshot, +} from "./feature-keyboard-nav/ExplainOverlay"; +import { + isTypingTarget, + navHintRows, + resolveNavBinding, + type NavKeyBinding, +} from "./feature-keyboard-nav/keymap"; +import { interiorPointOf, isAreaGeometry } 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 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; +const ACTIVE_COLOR = "#1677ff"; +const WARNING_COLOR = "#d4380d"; +const FADE_MS = 400; +/** half-size of the box `verifyWithRenderer` asks about, in pixels */ +const VERIFY_PROBE_PX = 3; + +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 +): NavOrigin | undefined => { + if (feature) { + const interior = interiorPointOf(feature.geometry); + if (interior) { + const projected = map.project([interior[0], interior[1]]); + const key = candidateKeyOf(feature); + 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; +}) => ( +
+ + {navHintRows({ hasActivateHandler }).map((row) => ( + + {row.keys}{" "} + {row.label} + + ))} + {degraded && ( + + + unvollständig + + + )} +
+); + +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, + 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, + autoActivateOnSelect = 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); + const isActive = isToolShape ? !suspended : mode?.isOn ?? false; + + 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); + + const publishExplanation = useCallback( + (map: MaplibreMap, explanation: PickExplanation) => { + if (explain === "off") return; + explainIdRef.current += 1; + setFaded(false); + setSnapshot(toExplainSnapshot(map, explanation, explainIdRef.current)); + }, + [explain] + ); + + useEffect(() => { + if (explain !== "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, explain, explainMs]); + + useEffect(() => { + if (isActive) return; + 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; + + const axis = NAV_AXES[direction]; + const constants = resolveNavConstants(config); + const maxAttempts = edgeBehavior === "pan" ? 2 : 1; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const origin = resolveOrigin(map, originFeatureRef.current); + if (!origin) return; + + const { candidates, byKey, degraded } = candidateSetRef.current; + + const projected = projectCandidates({ + map, + candidates, + origin: origin.point, + axis, + coneAngleDeg: constants.coneAngleDeg, + excludeKey: origin.key, + }); + + const { explanation } = pickInDirection({ + origin: origin.point, + axis, + candidates: projected, + constants, + originIsArea: origin.isArea, + currentLayerId: origin.layerId, + strategy, + crossLayer, + currentLayerBonus, + 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) { + originFeatureRef.current = winner.feature; + // 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; + } + + if (attempt + 1 >= maxAttempts) return; + const canvas = map.getCanvas(); + map.panBy( + [ + axis.x * canvas.clientWidth * panStepFraction, + axis.y * canvas.clientHeight * panStepFraction, + ], + { duration: panDurationMs } + ); + await waitForCandidates(); + } + }, + [ + libreMap, + config, + strategy, + crossLayer, + currentLayerBonus, + 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]); + + /** 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) { + // 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); + }); + } + 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] + ); + + const overlay = ( + + ); + + 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..d11da2e16d --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx @@ -0,0 +1,322 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { rotate } from "./geometry"; +import type { PickExplanation, 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; + +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); + +export const ExplainOverlay = ({ + map, + snapshot, + faded, + degraded = false, +}: { + map: MaplibreMap | null; + snapshot: ExplainSnapshot | null; + faded: boolean; + /** the candidate set was truncated or a query failed; said, not hidden */ + degraded?: 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( +
+ + {explanation.strategyUsed === "nearest-in-cone" && ( + + )} + + {(explanation.rays ?? []).map((ray, index) => { + const crossing = project(anchors.rays[index]); + const direction = rotate(axis, ray.angleDeg); + const end = crossing ?? { + x: origin.x + direction.x * AXIS_LENGTH_PX * 2, + y: origin.y + direction.y * AXIS_LENGTH_PX * 2, + }; + return ( + + + {crossing && ( + + )} + + ); + })} + + + + + {drawn.map(({ evaluation, index }, rank) => { + const point = project(anchors.evaluations[index]); + if (!point) return null; + const winner = isWinner(evaluation.key); + const color = winner + ? COLORS.winner + : evaluation.rejectedBecause + ? COLORS.rejected + : COLORS.candidate; + const label = evaluation.rejectedBecause + ? evaluation.rejectedBecause + : `d ${format(evaluation.distancePx)} · θ ${format( + evaluation.angleDeg + )}° · ${format(evaluation.cost)}`; + return ( + + + + {(winner || rank < MAX_LABELLED) && ( + + {label} + + )} + + ); + })} + + +
+ {explanation.strategyUsed} · θmax {format(explanation.coneAngleDeg)}° · + w {format(explanation.angleWeight)} · p {format(explanation.anglePower)}{" "} + · {explanation.evaluations.length} Kandidaten + {degraded && ( + + {" "} + · Kandidatenmenge unvollständig + + )} +
+
, + container + ); +}; 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..0e2c731340 --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts @@ -0,0 +1,132 @@ +import type { MapGeoJSONFeature } from "maplibre-gl"; +import type { 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 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]; +}; + +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(); + 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 existing = byKey.get(key); + if (existing) { + existing.parts.push(...parts); + 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; + + byKey.set(key, { + key, + styleLayerId: feature.layer?.id ?? "", + ...(catalogLayerId ? { catalogLayerId } : {}), + source: feature.source, + ...(feature.sourceLayer ? { sourceLayer: feature.sourceLayer } : {}), + feature, + 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..822967044e --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts @@ -0,0 +1,103 @@ +import type { FeatureKeyboardNavConfig, 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; +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; +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..189f1c45e4 --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts @@ -0,0 +1,222 @@ +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, + }, + }; +}; + +/** 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..f621bbd930 --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts @@ -0,0 +1,97 @@ +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, + minStepPx: 0.001, + fanDeg: 8, + rayLengthPx: 100, +}); + +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..48e286086f --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts @@ -0,0 +1,79 @@ +import { pointOnFeature } from "@turf/turf"; +import type { Feature, Geometry, Position } from "geojson"; + +/** + * 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. `pointOnFeature` is a guaranteed-inside operation + * rather than an average of coordinates, which is exactly the difference. + */ +export const interiorPointOf = ( + geometry: Geometry | null | undefined +): Position | undefined => { + if (!geometry) return undefined; + try { + const feature: Feature = { type: "Feature", properties: {}, geometry }; + const point = pointOnFeature(feature); + const [lng, lat] = point.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; + } +}; + +/** 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..4cbf235434 --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.spec.ts @@ -0,0 +1,220 @@ +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, + 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"); + }); +}); 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..86c520215f --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.ts @@ -0,0 +1,306 @@ +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); + + for (const candidate of input.candidates) { + if (isOutOfScope(candidate, input)) continue; + + let best: { t: number; point: ScreenPoint; rayIndex: 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; + } + // rayAngles starts with the centre ray, so a strict `<` lets it keep ties + if (!best || crossing.t < best.t) { + best = { ...crossing, rayIndex }; + } + } + + 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.t, + }); + } + + 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/projection.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts new file mode 100644 index 0000000000..9303037b0b --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts @@ -0,0 +1,79 @@ +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, +}: { + 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; +}): ProjectedCandidate[] => { + const projected: ProjectedCandidate[] = []; + + for (const candidate of candidates) { + if (candidate.key === excludeKey) 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..441127db17 --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts @@ -0,0 +1,165 @@ +import type { Positions } from "@carma-mapping/map-controls-layout"; + +/** + * 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; + + /** 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" */ + explain?: NavExplainMode; + /** Fade delay for "brief", in ms. Default: 1200 */ + explainMs?: 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; + 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..5387d4a772 --- /dev/null +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts @@ -0,0 +1,149 @@ +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; +}; + +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; + + 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; + } + } + + 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, + })); + return; + } + + const candidateSet = buildCandidates(features, { + catalogLayerIds, + requireCatalogLayer, + maxCandidates: maxCandidatesRef.current, + }); + 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; + 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", + }, + }, +}); From 800c5d0360152c2a41d25ca8b4f203965052f7f7 Mon Sep 17 00:00:00 2001 From: PavelOlkhovoi Date: Tue, 11 Aug 2026 18:17:22 +0200 Subject: [PATCH 02/20] #749 fix position of pick explanation component --- .../src/app/constants/fachzwillinge/boden.ts | 2 +- .../addons/src/addons/FeatureKeyboardNav.tsx | 23 ++++-- .../feature-keyboard-nav/ExplainOverlay.tsx | 76 ++++++++++++------- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts index 1f08b95ef6..aa5b5c4380 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts @@ -64,7 +64,7 @@ export const bodenFachzwilling: FachzwillingRoute = { config: { sharpness: 0.5, crossLayer: "prefer-current", - explain: "brief", + explain: "hold", }, }, { diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 7279d4353a..6f98e7603a 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -44,6 +44,7 @@ import { SHIFT_PAN_PX, } from "./feature-keyboard-nav/constants"; import { + ExplainLegend, ExplainOverlay, toExplainSnapshot, type ExplainSnapshot, @@ -106,6 +107,8 @@ export type FeatureNavigationModeState = { 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"; const FADE_MS = 400; @@ -634,13 +637,21 @@ export const FeatureKeyboardNav = ({ [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 = ( - + <> + + {snapshot && ( + + + + )} + ); if (!libreMap) return null; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx index d11da2e16d..2e802bda7d 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx @@ -104,13 +104,10 @@ export const ExplainOverlay = ({ map, snapshot, faded, - degraded = false, }: { map: MaplibreMap | null; snapshot: ExplainSnapshot | null; faded: boolean; - /** the candidate set was truncated or a query failed; said, not hidden */ - degraded?: boolean; }) => { useMapFrame(map); @@ -292,31 +289,56 @@ export const ExplainOverlay = ({ ); })} - -
- {explanation.strategyUsed} · θmax {format(explanation.coneAngleDeg)}° · - w {format(explanation.angleWeight)} · p {format(explanation.anglePower)}{" "} - · {explanation.evaluations.length} Kandidaten - {degraded && ( - - {" "} - · Kandidatenmenge unvollständig - - )} -
, container ); }; + +/** + * 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, +}: { + snapshot: ExplainSnapshot | null; + faded: boolean; + degraded?: boolean; +}) => { + if (!snapshot) return null; + const { explanation } = snapshot; + + return ( +
+ {explanation.strategyUsed} · θmax {format(explanation.coneAngleDeg)}° · w{" "} + {format(explanation.angleWeight)} · p {format(explanation.anglePower)} ·{" "} + {explanation.evaluations.length} Kandidaten + {degraded && ( + + {" "} + · Kandidatenmenge unvollständig + + )} +
+ ); +}; From 0892139fba84fe8cec923ebe8dd390ae3a449352 Mon Sep 17 00:00:00 2001 From: PavelOlkhovoi Date: Wed, 12 Aug 2026 14:07:05 +0200 Subject: [PATCH 03/20] #749 add all origins to explain mode --- .../src/app/constants/fachzwillinge/boden.ts | 4 + .../addons/src/addons/FeatureKeyboardNav.tsx | 40 +++++++++ .../feature-keyboard-nav/ExplainOverlay.tsx | 81 ++++++++++++++++++- .../addons/feature-keyboard-nav/candidates.ts | 33 +++++++- .../addons/feature-keyboard-nav/constants.ts | 5 ++ .../src/addons/feature-keyboard-nav/origin.ts | 2 +- .../src/addons/feature-keyboard-nav/types.ts | 25 ++++++ .../feature-keyboard-nav/useNavCandidates.ts | 6 ++ 8 files changed, 192 insertions(+), 4 deletions(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts index aa5b5c4380..ae5e1c6cbb 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts @@ -65,6 +65,10 @@ export const bodenFachzwilling: FachzwillingRoute = { sharpness: 0.5, crossLayer: "prefer-current", explain: "hold", + // 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, }, }, { diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 6f98e7603a..011ce6446f 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -33,7 +33,10 @@ import { DEFAULT_EXPLAIN_MS, DEFAULT_FAN_DEG, DEFAULT_MAX_CANDIDATES, + DEFAULT_MAX_ORIGIN_DOTS, DEFAULT_MIN_STEP_PX, + DEFAULT_ORIGIN_DOT_COLOR, + DEFAULT_ORIGIN_DOT_OPACITY, DEFAULT_PAN_DURATION_MS, DEFAULT_PAN_STEP_FRACTION, DEFAULT_STRATEGY, @@ -46,6 +49,7 @@ import { import { ExplainLegend, ExplainOverlay, + FeatureOriginDots, toExplainSnapshot, type ExplainSnapshot, } from "./feature-keyboard-nav/ExplainOverlay"; @@ -320,6 +324,10 @@ export const FeatureKeyboardNav = ({ panDurationMs = DEFAULT_PAN_DURATION_MS, explain = DEFAULT_EXPLAIN, explainMs = DEFAULT_EXPLAIN_MS, + showOrigins = false, + maxOriginDots = DEFAULT_MAX_ORIGIN_DOTS, + originDotColor = DEFAULT_ORIGIN_DOT_COLOR, + originDotOpacity = DEFAULT_ORIGIN_DOT_OPACITY, autoActivateOnSelect = false, showControl = true, controlPosition = DEFAULT_CONTROL_POSITION, @@ -353,12 +361,17 @@ export const FeatureKeyboardNav = ({ ); const scope = useNavScope(libreMap, target, layerPatterns); + // the dots are the only reason to compute an interior point per visible + // feature, so the flag reaches all the way down to the candidate build + const originsUpTo = showOrigins && explain !== "off" ? maxOriginDots : 0; + const { candidateSet, version } = useNavCandidates({ map: libreMap, scope, enabled: isActive, maxCandidates, debounceMs: candidateDebounceMs, + originsUpTo, }); const candidateSetRef = useRef(candidateSet); @@ -385,6 +398,24 @@ export const FeatureKeyboardNav = ({ [explain] ); + /** + * The interior points drawn while the mode runs. Not part of the keypress + * snapshot: they describe the map as it stands, so they appear the moment the + * mode is switched on and survive every pan, rather than arriving with the + * first arrow and fading with it. + */ + const featureOrigins = useMemo( + () => + originsUpTo === 0 + ? [] + : candidateSet.candidates + .map((candidate) => candidate.origin) + .filter( + (origin): origin is [number, number] => origin !== undefined + ), + [candidateSet, originsUpTo] + ); + useEffect(() => { if (explain !== "brief" || !snapshot) return; const fade = setTimeout(() => setFaded(true), explainMs); @@ -641,6 +672,15 @@ export const FeatureKeyboardNav = ({ // 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 && ( + + )} {snapshot && ( diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx index 2e802bda7d..b9260f9216 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx @@ -2,6 +2,10 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import type { Map as MaplibreMap } from "maplibre-gl"; +import { + DEFAULT_ORIGIN_DOT_COLOR, + DEFAULT_ORIGIN_DOT_OPACITY, +} from "./constants"; import { rotate } from "./geometry"; import type { PickExplanation, ScreenPoint } from "./types"; @@ -26,6 +30,11 @@ const MAX_DRAWN = 24; const MAX_LABELLED = 8; const AXIS_LENGTH_PX = 90; const FADE_MS = 400; +/** the origin dot, drawn at the same size for the selected feature and for the + * interior points of the others: they are the same kind of point */ +const ORIGIN_DOT_RADIUS = 5; +/** softens the possible origins so the actual one stays the sharp one */ +const ORIGIN_DOT_BLUR_PX = 1; export type ExplainSnapshot = { /** bumped per keypress; restarts the fade and replaces a held picture */ @@ -232,7 +241,7 @@ export const ExplainOverlay = ({ ; + color?: string; + opacity?: number; +}) => { + useMapFrame(map); + + if (!map || origins.length === 0) return null; + + return createPortal( + + {origins.map((lngLat, index) => { + const point = map.project(lngLat); + return ( + + ); + })} + , + map.getContainer() + ); +}; + /** * The constants that were in force, as a readout. * diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts index 0e2c731340..d14c0a18e6 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts @@ -3,7 +3,12 @@ import type { Position } from "geojson"; import { stampSourceLayerFromProperty } from "@carma-mapping/utils"; -import { bboxOfParts, isAreaGeometry, partsOfGeometry } from "./origin"; +import { + bboxOfParts, + interiorPointOf, + isAreaGeometry, + partsOfGeometry, +} from "./origin"; import { catalogLayerIdOfFeature } from "./scope"; /** @@ -31,6 +36,13 @@ export type NavCandidate = { isArea: boolean; /** geographic bounding box, for the cheap per-keypress prune */ bbox: [number, number, number, number]; + /** + * The interior point this feature would measure from if it were selected — + * the same `pointOnFeature` the origin uses. Only filled while the explain + * overlay asks for it, since navigation itself needs the origin of exactly + * one feature and computing it for every visible one is wasted work. + */ + origin?: [number, number]; }; export const candidateKeyOf = (feature: { @@ -70,10 +82,13 @@ export const buildCandidates = ( catalogLayerIds, requireCatalogLayer = false, maxCandidates, + originsUpTo = 0, }: { catalogLayerIds?: string[]; requireCatalogLayer?: boolean; maxCandidates: number; + /** compute `origin` for at most this many candidates; 0 for none */ + originsUpTo?: number; } ): CandidateSet => { const allowed = catalogLayerIds ? new Set(catalogLayerIds) : undefined; @@ -124,8 +139,22 @@ export const buildCandidates = ( }); } + const candidates = [...byKey.values()]; + + // after the merge, so a feature split across tiles gets the origin of the + // geometry that is actually handed to the selection path. Bounded: this is a + // drawing aid, and `pointOnFeature` per visible feature adds up. + for ( + let index = 0; + index < Math.min(originsUpTo, candidates.length); + index++ + ) { + const origin = interiorPointOf(candidates[index].feature.geometry); + if (origin) candidates[index].origin = origin; + } + return { - candidates: [...byKey.values()], + candidates, byKey, degraded: truncated, }; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts index 822967044e..cad0942fc2 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts @@ -40,6 +40,11 @@ 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; +/** how many interior-point dots `showOrigins` draws before it stops */ +export const DEFAULT_MAX_ORIGIN_DOTS = 300; +/** grey-blue, so a possible origin never reads as the actual one */ +export const DEFAULT_ORIGIN_DOT_COLOR = "#8a94a6"; +export const DEFAULT_ORIGIN_DOT_OPACITY = 0.7; export const DEFAULT_MAX_CANDIDATES = 4000; export const DEFAULT_CANDIDATE_DEBOUNCE_MS = 200; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts index 48e286086f..9ba6fa427d 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts @@ -12,7 +12,7 @@ import type { Feature, Geometry, Position } from "geojson"; */ export const interiorPointOf = ( geometry: Geometry | null | undefined -): Position | undefined => { +): [number, number] | undefined => { if (!geometry) return undefined; try { const feature: Feature = { type: "Feature", properties: {}, geometry }; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts index 441127db17..f84aa3050f 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts @@ -74,6 +74,31 @@ export type FeatureKeyboardNavConfig = { explain?: NavExplainMode; /** Fade delay for "brief", in ms. Default: 1200 */ explainMs?: number; + /** + * Draw the interior point of every visible shape as a blue dot, not only the + * origin of the selected one. These are the points navigation would measure + * 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. + * + * Shown for as long as the mode is on, not only after a keypress, so the + * picture is there before the first arrow. Needs `explain` on. Default: false + */ + showOrigins?: boolean; + /** + * Upper bound on those dots; beyond it the rest are left undrawn. They are + * re-projected on every map frame, so a high value costs redraw time while + * panning. Default: 300 + */ + maxOriginDots?: number; + /** + * Fill colour of those dots, as any CSS colour. Keep it clearly apart from + * the blue of the selected feature's own origin, which is not configurable + * for that reason. Default: "#8a94a6" + */ + originDotColor?: string; + /** Opacity of the whole dot layer, 0 to 1. Default: 0.7 */ + originDotOpacity?: number; /** Enter navigation mode as soon as a feature is selected. Default: false */ autoActivateOnSelect?: boolean; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts index 5387d4a772..6638228095 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts @@ -39,12 +39,15 @@ export const useNavCandidates = ({ enabled, maxCandidates, debounceMs, + originsUpTo = 0, }: { map: MaplibreMap | null; scope: NavScope; enabled: boolean; maxCandidates: number; debounceMs: number; + /** interior points to precompute for the explain overlay; 0 for none */ + originsUpTo?: number; }): NavCandidateState => { const [state, setState] = useState({ candidateSet: EMPTY_CANDIDATE_SET, @@ -57,6 +60,8 @@ export const useNavCandidates = ({ scopeRef.current = scope; const maxCandidatesRef = useRef(maxCandidates); maxCandidatesRef.current = maxCandidates; + const originsUpToRef = useRef(originsUpTo); + originsUpToRef.current = originsUpTo; const query = useCallback((mapInstance: MaplibreMap) => { const { styleLayerIds, catalogLayerIds, requireCatalogLayer } = @@ -103,6 +108,7 @@ export const useNavCandidates = ({ catalogLayerIds, requireCatalogLayer, maxCandidates: maxCandidatesRef.current, + originsUpTo: originsUpToRef.current, }); setState((previous) => ({ candidateSet, From 38842c96e5c3ac4034f4e674edf5d4a717e8ca02 Mon Sep 17 00:00:00 2001 From: PavelOlkhovoi Date: Wed, 12 Aug 2026 15:18:25 +0200 Subject: [PATCH 04/20] #749 change interior point function --- .../src/addons/feature-keyboard-nav/origin.ts | 81 +++++++++++++++++-- .../feature-keyboard-nav/polylabel.d.ts | 14 ++++ 2 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 libraries/mapping/addons/src/addons/feature-keyboard-nav/polylabel.d.ts diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts index 9ba6fa427d..d88943c7a9 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts @@ -1,5 +1,6 @@ import { pointOnFeature } from "@turf/turf"; import type { Feature, Geometry, Position } from "geojson"; +import polylabel from "polylabel"; /** * The point navigation measures from. @@ -7,17 +8,22 @@ import type { Feature, Geometry, Position } from "geojson"; * 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. `pointOnFeature` is a guaranteed-inside operation - * rather than an average of coordinates, which is exactly the difference. + * 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. */ -export const interiorPointOf = ( - geometry: Geometry | null | undefined -): [number, number] | undefined => { - if (!geometry) return undefined; + +/** 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 point = pointOnFeature(feature); - const [lng, lat] = point.geometry.coordinates; + const [lng, lat] = pointOnFeature(feature).geometry.coordinates; return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : undefined; @@ -27,6 +33,65 @@ export const interiorPointOf = ( } }; +export const interiorPointOf = ( + geometry: Geometry | null | undefined +): [number, number] | undefined => { + if (!geometry) return undefined; + + const parts: Position[][][] = + geometry.type === "Polygon" + ? [geometry.coordinates] + : geometry.type === "MultiPolygon" + ? geometry.coordinates + : []; + + let best: [number, number] | undefined; + let bestClearance = 0; + + for (const rings of parts) { + const outer = rings[0]; + if (!outer || outer.length < 4) continue; + + try { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of outer) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + const size = Math.max(maxX - minX, maxY - minY); + // 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 size keeps this correct in degrees and in metres alike. + if (!Number.isFinite(size) || size <= 0) continue; + + const result = polylabel(rings, size / 10000); + 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. On a MultiPolygon the roomiest part wins, so + // an island never steals the origin from the mainland. + if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue; + if (clearance > bestClearance) { + bestClearance = clearance; + best = [lng, lat]; + } + } catch { + // degenerate geometry (empty rings, NaN coordinates) from a broken tile + } + } + + // 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 best ?? onFeaturePointOf(geometry); +}; + /** Areas cast rays, everything else uses the cone. */ export const isAreaGeometry = (type: string | undefined): boolean => type === "Polygon" || type === "MultiPolygon"; 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 }; +} From 53e25ec569a84e73a8dca925632e63d65ce9014c Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 15:44:14 +0200 Subject: [PATCH 05/20] #749 draw the interior point only for the selected feature The origin dots were drawn for the whole candidate set, up to 300 of them, re-projected on every map frame under a CSS blur. On a viewport of ALKIS parcels that stalled the main thread while panning, and it also cost a polylabel run per visible feature on every idle (measured: 54ms per 300 real parcels). Now one dot, for the feature that is actually selected, from a click as well as from an arrow key, in the same blue as the selection outline and without the blur that softened a "possible" origin into an approximation. The per-candidate origin plumbing is gone with it: NavCandidate.origin, the originsUpTo bound and DEFAULT_MAX_ORIGIN_DOTS had no other user. Note that interiorPointOf itself was not at fault. Checked against the tiles the geoportal renders, z14/8517/5466: 12544 real polygons across landparcel, building, buildingpart, buildingstructure and landuse, every interior point strictly inside under booleanPointInPolygon with ignoreBoundary, no fallback to pointOnFeature. --- .../addons/src/addons/FeatureKeyboardNav.tsx | 39 +++++++++---------- .../feature-keyboard-nav/ExplainOverlay.tsx | 36 +++++++---------- .../addons/feature-keyboard-nav/candidates.ts | 33 +--------------- .../addons/feature-keyboard-nav/constants.ts | 8 ++-- .../src/addons/feature-keyboard-nav/types.ts | 30 +++++++------- .../feature-keyboard-nav/useNavCandidates.ts | 6 --- 6 files changed, 51 insertions(+), 101 deletions(-) diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 011ce6446f..006c7a19bf 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -33,7 +33,6 @@ import { DEFAULT_EXPLAIN_MS, DEFAULT_FAN_DEG, DEFAULT_MAX_CANDIDATES, - DEFAULT_MAX_ORIGIN_DOTS, DEFAULT_MIN_STEP_PX, DEFAULT_ORIGIN_DOT_COLOR, DEFAULT_ORIGIN_DOT_OPACITY, @@ -325,7 +324,6 @@ export const FeatureKeyboardNav = ({ explain = DEFAULT_EXPLAIN, explainMs = DEFAULT_EXPLAIN_MS, showOrigins = false, - maxOriginDots = DEFAULT_MAX_ORIGIN_DOTS, originDotColor = DEFAULT_ORIGIN_DOT_COLOR, originDotOpacity = DEFAULT_ORIGIN_DOT_OPACITY, autoActivateOnSelect = false, @@ -361,17 +359,12 @@ export const FeatureKeyboardNav = ({ ); const scope = useNavScope(libreMap, target, layerPatterns); - // the dots are the only reason to compute an interior point per visible - // feature, so the flag reaches all the way down to the candidate build - const originsUpTo = showOrigins && explain !== "off" ? maxOriginDots : 0; - const { candidateSet, version } = useNavCandidates({ map: libreMap, scope, enabled: isActive, maxCandidates, debounceMs: candidateDebounceMs, - originsUpTo, }); const candidateSetRef = useRef(candidateSet); @@ -399,21 +392,27 @@ export const FeatureKeyboardNav = ({ ); /** - * The interior points drawn while the mode runs. Not part of the keypress - * snapshot: they describe the map as it stands, so they appear the moment the - * mode is switched on and survive every pan, rather than arriving with the - * first arrow and fading with it. + * 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( - () => - originsUpTo === 0 - ? [] - : candidateSet.candidates - .map((candidate) => candidate.origin) - .filter( - (origin): origin is [number, number] => origin !== undefined - ), - [candidateSet, originsUpTo] + () => { + if (!showOrigins || explain === "off" || !rawFeature) return []; + const origin = interiorPointOf(rawFeature.geometry); + return origin ? [origin] : []; + }, + // `selectionVersion` stands for a reselection of the same feature object + // eslint-disable-next-line react-hooks/exhaustive-deps + [showOrigins, explain, rawFeature, selectionVersion] ); useEffect(() => { diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx index b9260f9216..c1eb001ed2 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx @@ -30,11 +30,9 @@ const MAX_DRAWN = 24; const MAX_LABELLED = 8; const AXIS_LENGTH_PX = 90; const FADE_MS = 400; -/** the origin dot, drawn at the same size for the selected feature and for the - * interior points of the others: they are the same kind of point */ +/** 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; -/** softens the possible origins so the actual one stays the sharp one */ -const ORIGIN_DOT_BLUR_PX = 1; export type ExplainSnapshot = { /** bumped per keypress; restarts the fade and replaces a held picture */ @@ -304,26 +302,21 @@ export const ExplainOverlay = ({ }; /** - * The interior point of every visible shape, as a blue dot. + * The interior point of the selected feature, as a blue dot. * - * Its own overlay, not part of the per-keypress picture: these answer "where - * would a step from that shape start?", which is a property of the map as it - * stands, not of one decision. They therefore appear as soon as the mode is - * switched on, before any key is pressed, and stay while the user pans. + * 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. * - * Same size as the origin dot of the selected feature, because it is the same - * kind of point, but softened and in its own colour: these are origins a step - * *could* start from, and only one of them is the one it did start from. The - * blur is a CSS filter on the whole layer rather than an SVG `feGaussianBlur` - * per dot — the layer is composited once, which matters when it is re-projected - * on every map frame. + * 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. * - * Colour and opacity are config, since what reads as "clearly not the selected - * one" depends on the basemap under it. Opacity sits on the layer rather than on - * each circle, so overlapping dots do not darken each other. - * - * The set is the whole candidate set, not the evaluated candidates: the shapes - * a step skipped are exactly the ones whose interior point explains why. + * 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. */ export const FeatureOriginDots = ({ map, @@ -350,7 +343,6 @@ export const FeatureOriginDots = ({ pointerEvents: "none", zIndex: 599, opacity, - filter: `blur(${ORIGIN_DOT_BLUR_PX}px)`, }} data-test-id="feature-keyboard-nav-origins" > diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts index d14c0a18e6..0e2c731340 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts @@ -3,12 +3,7 @@ import type { Position } from "geojson"; import { stampSourceLayerFromProperty } from "@carma-mapping/utils"; -import { - bboxOfParts, - interiorPointOf, - isAreaGeometry, - partsOfGeometry, -} from "./origin"; +import { bboxOfParts, isAreaGeometry, partsOfGeometry } from "./origin"; import { catalogLayerIdOfFeature } from "./scope"; /** @@ -36,13 +31,6 @@ export type NavCandidate = { isArea: boolean; /** geographic bounding box, for the cheap per-keypress prune */ bbox: [number, number, number, number]; - /** - * The interior point this feature would measure from if it were selected — - * the same `pointOnFeature` the origin uses. Only filled while the explain - * overlay asks for it, since navigation itself needs the origin of exactly - * one feature and computing it for every visible one is wasted work. - */ - origin?: [number, number]; }; export const candidateKeyOf = (feature: { @@ -82,13 +70,10 @@ export const buildCandidates = ( catalogLayerIds, requireCatalogLayer = false, maxCandidates, - originsUpTo = 0, }: { catalogLayerIds?: string[]; requireCatalogLayer?: boolean; maxCandidates: number; - /** compute `origin` for at most this many candidates; 0 for none */ - originsUpTo?: number; } ): CandidateSet => { const allowed = catalogLayerIds ? new Set(catalogLayerIds) : undefined; @@ -139,22 +124,8 @@ export const buildCandidates = ( }); } - const candidates = [...byKey.values()]; - - // after the merge, so a feature split across tiles gets the origin of the - // geometry that is actually handed to the selection path. Bounded: this is a - // drawing aid, and `pointOnFeature` per visible feature adds up. - for ( - let index = 0; - index < Math.min(originsUpTo, candidates.length); - index++ - ) { - const origin = interiorPointOf(candidates[index].feature.geometry); - if (origin) candidates[index].origin = origin; - } - return { - candidates, + candidates: [...byKey.values()], byKey, degraded: truncated, }; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts index cad0942fc2..08e273a29c 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts @@ -40,11 +40,9 @@ 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; -/** how many interior-point dots `showOrigins` draws before it stops */ -export const DEFAULT_MAX_ORIGIN_DOTS = 300; -/** grey-blue, so a possible origin never reads as the actual one */ -export const DEFAULT_ORIGIN_DOT_COLOR = "#8a94a6"; -export const DEFAULT_ORIGIN_DOT_OPACITY = 0.7; +/** 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; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts index f84aa3050f..264b3f4691 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts @@ -75,29 +75,25 @@ export type FeatureKeyboardNavConfig = { /** Fade delay for "brief", in ms. Default: 1200 */ explainMs?: number; /** - * Draw the interior point of every visible shape as a blue dot, not only the - * origin of the selected one. These are the points navigation would measure - * 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. + * 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 as long as the mode is on, not only after a keypress, so the - * picture is there before the first arrow. Needs `explain` on. Default: false + * 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; /** - * Upper bound on those dots; beyond it the rest are left undrawn. They are - * re-projected on every map frame, so a high value costs redraw time while - * panning. Default: 300 - */ - maxOriginDots?: number; - /** - * Fill colour of those dots, as any CSS colour. Keep it clearly apart from - * the blue of the selected feature's own origin, which is not configurable - * for that reason. Default: "#8a94a6" + * 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 whole dot layer, 0 to 1. Default: 0.7 */ + /** Opacity of the dot layer, 0 to 1. Default: 1 */ originDotOpacity?: number; /** Enter navigation mode as soon as a feature is selected. Default: false */ diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts index 6638228095..5387d4a772 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts @@ -39,15 +39,12 @@ export const useNavCandidates = ({ enabled, maxCandidates, debounceMs, - originsUpTo = 0, }: { map: MaplibreMap | null; scope: NavScope; enabled: boolean; maxCandidates: number; debounceMs: number; - /** interior points to precompute for the explain overlay; 0 for none */ - originsUpTo?: number; }): NavCandidateState => { const [state, setState] = useState({ candidateSet: EMPTY_CANDIDATE_SET, @@ -60,8 +57,6 @@ export const useNavCandidates = ({ scopeRef.current = scope; const maxCandidatesRef = useRef(maxCandidates); maxCandidatesRef.current = maxCandidates; - const originsUpToRef = useRef(originsUpTo); - originsUpToRef.current = originsUpTo; const query = useCallback((mapInstance: MaplibreMap) => { const { styleLayerIds, catalogLayerIds, requireCatalogLayer } = @@ -108,7 +103,6 @@ export const useNavCandidates = ({ catalogLayerIds, requireCatalogLayer, maxCandidates: maxCandidatesRef.current, - originsUpTo: originsUpToRef.current, }); setState((previous) => ({ candidateSet, From 497610f20e349489820969e6512dcb97a23a1c72 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 15:51:13 +0200 Subject: [PATCH 06/20] #749 clear the explain picture on a hand-made selection, and walk the trail back Two things the mode got wrong once the user took over from the arrow keys. A selection the addon did not publish now erases the explanation at once. The picture describes one step, and after a click it describes a decision that no longer led to what is selected; under explain: "hold" it stayed on screen next to the wrong feature until the next keypress. Steps mark the selection they are about to cause, so only the ones that arrive unclaimed count as hand-made. Up and then down now lands on the feature it started from. Picking could not 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. Every step records the feature it came from, and the opposite key pops that trail instead of asking the picker, which is also cheaper, since it projects no candidates at all. A click ends the walk and drops the trail with it, as does leaving the mode. --- .../addons/src/addons/FeatureKeyboardNav.tsx | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 006c7a19bf..9b60b0e8a9 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -118,6 +118,23 @@ 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", +}; +/** steps the trail remembers; older ones are dropped from the far end */ +const MAX_NAV_TRAIL = 100; + +/** One taken step, kept so the opposite key can undo it. */ +type NavTrailEntry = { + direction: NavDirection; + /** the feature the step started from, as it was queried */ + from: MapGeoJSONFeature; +}; + export const featureKeyboardNavTrigger: AddonTrigger<"featureKeyboardNav"> = { icon: faArrowsUpDownLeftRight, label: () => "Mit Pfeiltasten durch die Objekte navigieren", @@ -381,6 +398,47 @@ export const FeatureKeyboardNav = ({ const [faded, setFaded] = useState(false); const explainIdRef = useRef(0); + /** The key the last step published, so the selection it causes can be told + * apart from one the user made. Consumed on arrival: selecting the same + * feature again by hand is a new selection and clears the picture too. */ + const navSelectedKeyRef = useRef(undefined); + + /** + * The steps taken so far, newest last. + * + * 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 was second on the way out can + * win on the way back. The trail makes the return exact rather than merely + * likely, and it is also the cheaper path, since walking back costs no + * projection of the candidate set at all. + */ + const trailRef = useRef([]); + + /** + * 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) { + navSelectedKeyRef.current = undefined; + return; + } + navSelectedKeyRef.current = undefined; + trailRef.current = []; + setSnapshot(null); + setFaded(false); + }, [rawFeature, selectionVersion]); + const publishExplanation = useCallback( (map: MaplibreMap, explanation: PickExplanation) => { if (explain === "off") return; @@ -428,6 +486,7 @@ export const FeatureKeyboardNav = ({ useEffect(() => { if (isActive) return; + trailRef.current = []; setSnapshot(null); }, [isActive]); @@ -466,6 +525,30 @@ export const FeatureKeyboardNav = ({ const map = libreMap; if (!map) return; + // walking the trail back: the opposite of the last step returns to the + // feature that step came from, exactly, without asking the picker + const trail = trailRef.current; + const lastStep = trail[trail.length - 1]; + if (lastStep && direction === OPPOSITE_DIRECTION[lastStep.direction]) { + trail.pop(); + const previous = lastStep.from; + originFeatureRef.current = previous; + navSelectedKeyRef.current = candidateKeyOf(previous); + // a back-step is not a decision, so it leaves no picture behind + setSnapshot(null); + selectFeature( + { + source: previous.source, + sourceLayer: previous.sourceLayer, + id: previous.id, + }, + previous + ); + const restored = resolveOrigin(map, previous); + if (restored) keepInView(map, restored.point, panDurationMs); + return; + } + const axis = NAV_AXES[direction]; const constants = resolveNavConstants(config); const maxAttempts = edgeBehavior === "pan" ? 2 : 1; @@ -511,7 +594,17 @@ export const FeatureKeyboardNav = ({ winnerKey === undefined ? undefined : byKey.get(winnerKey); if (winner) { + const cameFrom = originFeatureRef.current; + // no entry for the bootstrap step: it started from the middle of the + // screen, and there is no feature to go back to + if (cameFrom) { + trail.push({ direction, from: cameFrom }); + if (trail.length > MAX_NAV_TRAIL) trail.shift(); + } 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( From e7dcb04e13c6235b0075ccb099589e6f232c35f1 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 16:43:59 +0200 Subject: [PATCH 07/20] #749 scale polylabel precision by the narrow side of the polygon Measured in the geoportal: single steps cost up to 1461ms, all of it in interiorPointOf. The culprit was a 40m parcel with five holes and 252 vertices, not a big one: polylabel quarters cells until one falls below the precision, and size/10000 asked for ~6mm there while the holes kept its queue from pruning anything. min(width, height)/50 is a fixed number of refinement rounds whatever the shape, and stays below the clearance a thin polygon can offer, so the distance > 0 guard still passes and no origin falls back to a boundary vertex. Same steps after the change: 19ms worst, 1-2ms typical. --- .../src/addons/feature-keyboard-nav/origin.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts index d88943c7a9..6b26987082 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts @@ -63,13 +63,33 @@ export const interiorPointOf = ( if (x > maxX) maxX = x; if (y > maxY) maxY = y; } - const size = Math.max(maxX - minX, maxY - minY); + const width = maxX - minX; + const height = maxY - minY; // 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 size keeps this correct in degrees and in metres alike. - if (!Number.isFinite(size) || size <= 0) continue; + // the polygon keeps this correct in degrees and in metres alike. + if (!Number.isFinite(width) || !Number.isFinite(height)) continue; + if (width <= 0 || height <= 0) continue; - const result = polylabel(rings, size / 10000); + /** + * 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; From 8acde0c9e8e67e0c4598e35a2331e9e31cb300a6 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 16:44:07 +0200 Subject: [PATCH 08/20] #749 rebuild the candidate set only when the view changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit idle fires after tile loads, after fades and after every keepInView pan, so a held arrow re-queried the whole viewport between steps. The set now carries the view it was built for — scope, centre to about a metre, zoom, bearing, pitch — and an idle on an unchanged view is ignored. The tiles-loaded flag is part of that signature, so a view that gains features while its tiles arrive is still rebuilt once, and a failed query clears it so the next idle retries. --- .../feature-keyboard-nav/useNavCandidates.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts index 5387d4a772..70a5774f67 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts @@ -33,6 +33,30 @@ export type NavCandidateState = { 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, @@ -57,6 +81,8 @@ export const useNavCandidates = ({ 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); const query = useCallback((mapInstance: MaplibreMap) => { const { styleLayerIds, catalogLayerIds, requireCatalogLayer } = @@ -82,6 +108,10 @@ export const useNavCandidates = ({ } } + const signature = viewSignature(mapInstance, navScopeKey(scopeRef.current)); + if (signature === signatureRef.current) return; + signatureRef.current = signature; + let features: MapGeoJSONFeature[]; try { features = mapInstance.queryRenderedFeatures( @@ -96,6 +126,8 @@ export const useNavCandidates = ({ 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; } @@ -138,6 +170,9 @@ export const useNavCandidates = ({ // 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; setState((previous) => previous.candidateSet === EMPTY_CANDIDATE_SET && previous.version === 0 ? previous From 24101c24facfbb098c3995c45d052ff650476ea0 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 16:44:22 +0200 Subject: [PATCH 09/20] #749 measure from where the walk arrived, over the whole feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reasons the origin could sit on a boundary and send the next step into the wrong neighbour. queryRenderedFeatures returns a feature once per tile it touches, and the origin was taken from whichever piece arrived first, so on anything larger than a tile it was the interior point of a fragment. Pieces are now merged per candidate and the origin comes from the merge. Measured in the app: every selection arrived in 3 to 12 pieces, one of eight moved 48m once merged. Most of those pieces were not tile fragments but the same geometry returned once per style layer drawing the feature, multiplying the work; identical pieces are now recognised and merged once. And a feature walked into now measures from the middle of the stretch the step's ray spent inside it, not from its pole. Crossings along a ray alternate outside/inside, so that midpoint is strictly inside, holes included, and it lies where the user arrived rather than where the shape happens to be widest — entering a long street from the south, the pole can be hundreds of metres down the road. Drawn red to tell it from the pole, which still serves a feature picked by click. Also: a held arrow started a step per OS key repeat, each measuring from a selection its predecessor had already replaced. Steps are dropped while one runs rather than queued, so releasing the key ends the walk. --- .../addons/src/addons/FeatureKeyboardNav.tsx | 116 ++++++++++++++++-- .../addons/feature-keyboard-nav/candidates.ts | 103 +++++++++++++++- .../addons/feature-keyboard-nav/geometry.ts | 51 ++++++++ 3 files changed, 256 insertions(+), 14 deletions(-) diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 9b60b0e8a9..a8b347c754 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -52,6 +52,7 @@ import { toExplainSnapshot, type ExplainSnapshot, } from "./feature-keyboard-nav/ExplainOverlay"; +import { chordMidpoint } from "./feature-keyboard-nav/geometry"; import { isTypingTarget, navHintRows, @@ -114,6 +115,8 @@ const DEFAULT_CONTROL_ORDER = 75; const LEGEND_CONTROL_ORDER = 10; const ACTIVE_COLOR = "#1677ff"; const WARNING_COLOR = "#d4380d"; +/** the origin a step arrived at, as opposed to the feature's own pole */ +const ARRIVAL_ORIGIN_COLOR = "#e8323c"; const FADE_MS = 400; /** half-size of the box `verifyWithRenderer` asks about, in pixels */ const VERIFY_PROBE_PX = 3; @@ -153,13 +156,24 @@ type NavOrigin = { const resolveOrigin = ( map: MaplibreMap, - feature: MapGeoJSONFeature | null + feature: MapGeoJSONFeature | null, + candidates: CandidateSet, + arrivals: Map ): NavOrigin | undefined => { if (feature) { - const interior = interiorPointOf(feature.geometry); + 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 = key ? arrivals.get(key) : undefined; + // 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); if (interior) { const projected = map.project([interior[0], interior[1]]); - const key = candidateKeyOf(feature); const layerId = catalogLayerIdOfFeature(feature); return { point: { x: projected.x, y: projected.y }, @@ -415,6 +429,17 @@ export const FeatureKeyboardNav = ({ */ const trailRef = useRef([]); + /** + * 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. * @@ -435,6 +460,8 @@ export const FeatureKeyboardNav = ({ } navSelectedKeyRef.current = undefined; trailRef.current = []; + arrivalsRef.current.clear(); + setArrivalKey(undefined); setSnapshot(null); setFaded(false); }, [rawFeature, selectionVersion]); @@ -465,14 +492,26 @@ export const FeatureKeyboardNav = ({ const featureOrigins = useMemo( () => { if (!showOrigins || explain === "off" || !rawFeature) return []; - const origin = interiorPointOf(rawFeature.geometry); + // 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 = key ? arrivalsRef.current.get(key) : undefined; + if (arrival) return [arrival]; + const merged = key ? candidateSet.byKey.get(key)?.geometry : undefined; + const origin = interiorPointOf(merged ?? rawFeature.geometry); return origin ? [origin] : []; }, - // `selectionVersion` stands for a reselection of the same feature object + // `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, explain, rawFeature, selectionVersion] + [showOrigins, explain, rawFeature, selectionVersion, candidateSet, arrivalKey] ); + /** Red where the walk arrived, the configured colour where it is the pole. */ + const originIsArrival = + rawFeature !== null && + arrivalsRef.current.has(candidateKeyOf(rawFeature) ?? ""); + useEffect(() => { if (explain !== "brief" || !snapshot) return; const fade = setTimeout(() => setFaded(true), explainMs); @@ -487,6 +526,8 @@ export const FeatureKeyboardNav = ({ useEffect(() => { if (isActive) return; trailRef.current = []; + arrivalsRef.current.clear(); + setArrivalKey(undefined); setSnapshot(null); }, [isActive]); @@ -544,7 +585,12 @@ export const FeatureKeyboardNav = ({ }, previous ); - const restored = resolveOrigin(map, previous); + const restored = resolveOrigin( + map, + previous, + candidateSetRef.current, + arrivalsRef.current + ); if (restored) keepInView(map, restored.point, panDurationMs); return; } @@ -554,10 +600,16 @@ export const FeatureKeyboardNav = ({ const maxAttempts = edgeBehavior === "pan" ? 2 : 1; for (let attempt = 0; attempt < maxAttempts; attempt++) { - const origin = resolveOrigin(map, originFeatureRef.current); + const candidateSetNow = candidateSetRef.current; + const origin = resolveOrigin( + map, + originFeatureRef.current, + candidateSetNow, + arrivalsRef.current + ); if (!origin) return; - const { candidates, byKey, degraded } = candidateSetRef.current; + const { candidates, byKey, degraded } = candidateSetNow; const projected = projectCandidates({ map, @@ -594,6 +646,29 @@ export const FeatureKeyboardNav = ({ 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 = 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; // no entry for the bootstrap step: it started from the middle of the // screen, and there is no feature to go back to @@ -672,6 +747,9 @@ export const FeatureKeyboardNav = ({ 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 @@ -681,11 +759,23 @@ export const FeatureKeyboardNav = ({ 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); - }); + step(binding.direction) + .catch((error: unknown) => { + console.warn("[FEATURE_KEYBOARD_NAV] step failed", error); + }) + .finally(() => { + steppingRef.current = false; + }); } return; case "pan": { @@ -769,7 +859,7 @@ export const FeatureKeyboardNav = ({ )} diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts index 0e2c731340..811953aa89 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/candidates.ts @@ -1,5 +1,5 @@ import type { MapGeoJSONFeature } from "maplibre-gl"; -import type { Position } from "geojson"; +import type { Geometry, Position } from "geojson"; import { stampSourceLayerFromProperty } from "@carma-mapping/utils"; @@ -26,6 +26,18 @@ export type NavCandidate = { 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; @@ -33,6 +45,78 @@ export type NavCandidate = { 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; @@ -78,6 +162,8 @@ export const buildCandidates = ( ): 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) { @@ -95,9 +181,19 @@ export const buildCandidates = ( 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; @@ -111,6 +207,10 @@ export const buildCandidates = ( const bbox = bboxOfParts(parts); if (!bbox) continue; + piecesByKey.set( + key, + new Set(signature === undefined ? [] : [signature]) + ); byKey.set(key, { key, styleLayerId: feature.layer?.id ?? "", @@ -118,6 +218,7 @@ export const buildCandidates = ( source: feature.source, ...(feature.sourceLayer ? { sourceLayer: feature.sourceLayer } : {}), feature, + geometry: feature.geometry, parts, isArea: isAreaGeometry(feature.geometry.type), bbox, diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts index 189f1c45e4..76eea79143 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/geometry.ts @@ -150,6 +150,57 @@ export const firstCrossing = ( }; }; +/** + * 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; From 3bb380dedc793744e39eb989e6359df5b7c7d730 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 16:46:06 +0200 Subject: [PATCH 10/20] #749 start the Boden Fachzwilling in navigation mode Development convenience while the mode is what this Fachzwilling is being built around: switch `startActive` back off in boden.ts before this merges. A fallback for the mode's value rather than a write on mount, because the addon state provider drops every channel when its scope identity changes and anything written once is lost with it. The control keeps working: switching off stores isOn: false, and a stored value beats the fallback. --- .../src/app/constants/fachzwillinge/boden.ts | 3 +++ .../mapping/addons/src/addons/FeatureKeyboardNav.tsx | 11 ++++++++++- .../addons/src/addons/feature-keyboard-nav/types.ts | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts index ae5e1c6cbb..ea7753d44e 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts @@ -65,6 +65,9 @@ export const bodenFachzwilling: FachzwillingRoute = { sharpness: 0.5, crossLayer: "prefer-current", explain: "hold", + // the mode is what this Fachzwilling is being built around right now, + // so it starts switched on rather than one click away + startActive: true, // 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 diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index a8b347c754..b8b6cf7216 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -358,6 +358,7 @@ export const FeatureKeyboardNav = ({ originDotColor = DEFAULT_ORIGIN_DOT_COLOR, originDotOpacity = DEFAULT_ORIGIN_DOT_OPACITY, autoActivateOnSelect = false, + startActive = false, showControl = true, controlPosition = DEFAULT_CONTROL_POSITION, controlOrder = DEFAULT_CONTROL_ORDER, @@ -373,7 +374,15 @@ export const FeatureKeyboardNav = ({ * mode suspends navigation until the button is toggled off and on again. */ const [suspended, setSuspended] = useState(false); - const isActive = isToolShape ? !suspended : mode?.isOn ?? 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(); diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts index 264b3f4691..be413f58b9 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts @@ -74,6 +74,13 @@ export type FeatureKeyboardNavConfig = { explain?: NavExplainMode; /** Fade delay for "brief", in ms. Default: 1200 */ explainMs?: number; + /** + * 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. From dcdc00fe84da52b0d0101627a7cab14cef278553 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 17:40:04 +0200 Subject: [PATCH 11/20] #749 pick the origin by strategy, and replay the walk in both directions The pole says nothing about the middle of a corridor: every point is equally far from the two long sides, so the widest spot is wherever a junction bulges, usually near an end. Two alternatives, chosen by hand, no automatic pick yet. originStrategy: - "pole" (default), unchanged, the only one guaranteed to lie inside - "spine", the middle along the shape. The vertex furthest from the centroid and the vertex furthest from that one are the ends; they cut the outer ring into two chains, and the average of their arc-length midpoints is half way down a street. O(n) - "centroid", the area centroid Neither of the two has a containment guarantee, so each is tested against the rings and falls back to the pole where it lands outside, which a bent shape does. originMode: "dynamic" keeps the arrival origin, the red point where a walk entered the feature; "static" always uses the computed one. All origins are drawn at once while the choice is open, one colour each and the one in force larger, a hollow dot marking a point that fell outside and is therefore unused. originDotColor is gone, since colour now identifies the strategy. The walk itself is a path with a cursor rather than a stack. Stepping back used to pop the entry and throw the way forward away, so up-up-up-down-down- up asked the picker again for that last up although the answer was known. Back replays while the key undoes the step that led here, forward while it repeats the step taken from here, and only a step leaving the path is picked; stepping off the middle drops what lay ahead, as an edit after an undo does. Tests: a corridor with a bulge near one end (pole in the bulge, spine near the middle), the C-shape falling back from centroid to pole, and a square using its centroid. --- .../src/app/constants/fachzwillinge/boden.ts | 7 + .../addons/src/addons/FeatureKeyboardNav.tsx | 225 ++++++++--- .../feature-keyboard-nav/ExplainOverlay.tsx | 32 +- .../feature-keyboard-nav/origin.spec.ts | 72 ++++ .../src/addons/feature-keyboard-nav/origin.ts | 350 ++++++++++++++---- .../src/addons/feature-keyboard-nav/types.ts | 27 ++ 6 files changed, 582 insertions(+), 131 deletions(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts index ea7753d44e..148b6d9930 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts @@ -68,6 +68,13 @@ export const bodenFachzwilling: FachzwillingRoute = { // 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 diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index b8b6cf7216..7b53d21911 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -34,7 +34,6 @@ import { DEFAULT_FAN_DEG, DEFAULT_MAX_CANDIDATES, DEFAULT_MIN_STEP_PX, - DEFAULT_ORIGIN_DOT_COLOR, DEFAULT_ORIGIN_DOT_OPACITY, DEFAULT_PAN_DURATION_MS, DEFAULT_PAN_STEP_FRACTION, @@ -51,6 +50,7 @@ import { FeatureOriginDots, toExplainSnapshot, type ExplainSnapshot, + type OriginDot, } from "./feature-keyboard-nav/ExplainOverlay"; import { chordMidpoint } from "./feature-keyboard-nav/geometry"; import { @@ -59,7 +59,12 @@ import { resolveNavBinding, type NavKeyBinding, } from "./feature-keyboard-nav/keymap"; -import { interiorPointOf, isAreaGeometry } from "./feature-keyboard-nav/origin"; +import { + interiorPointOf, + isAreaGeometry, + originCandidatesOf, + type OriginStrategy, +} from "./feature-keyboard-nav/origin"; import { pickInDirection, rankedKeys } from "./feature-keyboard-nav/pick"; import { projectCandidates, @@ -115,8 +120,21 @@ const DEFAULT_CONTROL_ORDER = 75; const LEGEND_CONTROL_ORDER = 10; const ACTIVE_COLOR = "#1677ff"; const WARNING_COLOR = "#d4380d"; -/** the origin a step arrived at, as opposed to the feature's own pole */ -const ARRIVAL_ORIGIN_COLOR = "#e8323c"; +/** + * 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; @@ -128,16 +146,30 @@ const OPPOSITE_DIRECTION: Readonly> = { left: "right", right: "left", }; -/** steps the trail remembers; older ones are dropped from the far end */ +/** features the path remembers; older ones are dropped from the far end */ const MAX_NAV_TRAIL = 100; -/** One taken step, kept so the opposite key can undo it. */ -type NavTrailEntry = { - direction: NavDirection; - /** the feature the step started from, as it was queried */ - from: MapGeoJSONFeature; +/** 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", @@ -158,7 +190,9 @@ const resolveOrigin = ( map: MaplibreMap, feature: MapGeoJSONFeature | null, candidates: CandidateSet, - arrivals: Map + arrivals: Map, + strategy: OriginStrategy, + mode: "dynamic" | "static" ): NavOrigin | undefined => { if (feature) { const key = candidateKeyOf(feature); @@ -166,12 +200,13 @@ const resolveOrigin = ( // 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 = key ? arrivals.get(key) : undefined; + 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); + const interior = + arrival ?? interiorPointOf(merged ?? feature.geometry, strategy); if (interior) { const projected = map.project([interior[0], interior[1]]); const layerId = catalogLayerIdOfFeature(feature); @@ -355,8 +390,9 @@ export const FeatureKeyboardNav = ({ explain = DEFAULT_EXPLAIN, explainMs = DEFAULT_EXPLAIN_MS, showOrigins = false, - originDotColor = DEFAULT_ORIGIN_DOT_COLOR, originDotOpacity = DEFAULT_ORIGIN_DOT_OPACITY, + originStrategy = "pole", + originMode = "dynamic", autoActivateOnSelect = false, startActive = false, showControl = true, @@ -427,16 +463,16 @@ export const FeatureKeyboardNav = ({ const navSelectedKeyRef = useRef(undefined); /** - * The steps taken so far, newest last. + * 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 was second on the way out can - * win on the way back. The trail makes the return exact rather than merely - * likely, and it is also the cheaper path, since walking back costs no - * projection of the candidate set at all. + * 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 trailRef = useRef([]); + const pathRef = useRef({ entries: [], cursor: -1 }); /** * Where a step entered each feature it walked into, in lng/lat. @@ -468,7 +504,7 @@ export const FeatureKeyboardNav = ({ return; } navSelectedKeyRef.current = undefined; - trailRef.current = []; + pathRef.current = { entries: [], cursor: -1 }; arrivalsRef.current.clear(); setArrivalKey(undefined); setSnapshot(null); @@ -504,22 +540,70 @@ export const FeatureKeyboardNav = ({ // 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 = key ? arrivalsRef.current.get(key) : undefined; - if (arrival) return [arrival]; + const arrival = + originMode === "static" || !key + ? undefined + : arrivalsRef.current.get(key); const merged = key ? candidateSet.byKey.get(key)?.geometry : undefined; - const origin = interiorPointOf(merged ?? rawFeature.geometry); - return origin ? [origin] : []; + 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, explain, rawFeature, selectionVersion, candidateSet, arrivalKey] + [ + showOrigins, + explain, + rawFeature, + selectionVersion, + candidateSet, + arrivalKey, + originStrategy, + originMode, + ] ); - /** Red where the walk arrived, the configured colour where it is the pole. */ - const originIsArrival = - rawFeature !== null && - arrivalsRef.current.has(candidateKeyOf(rawFeature) ?? ""); useEffect(() => { if (explain !== "brief" || !snapshot) return; @@ -534,7 +618,7 @@ export const FeatureKeyboardNav = ({ useEffect(() => { if (isActive) return; - trailRef.current = []; + pathRef.current = { entries: [], cursor: -1 }; arrivalsRef.current.clear(); setArrivalKey(undefined); setSnapshot(null); @@ -575,30 +659,45 @@ export const FeatureKeyboardNav = ({ const map = libreMap; if (!map) return; - // walking the trail back: the opposite of the last step returns to the - // feature that step came from, exactly, without asking the picker - const trail = trailRef.current; - const lastStep = trail[trail.length - 1]; - if (lastStep && direction === OPPOSITE_DIRECTION[lastStep.direction]) { - trail.pop(); - const previous = lastStep.from; - originFeatureRef.current = previous; - navSelectedKeyRef.current = candidateKeyOf(previous); - // a back-step is not a decision, so it leaves no picture behind + /** + * 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: previous.source, - sourceLayer: previous.sourceLayer, - id: previous.id, + source: remembered.source, + sourceLayer: remembered.sourceLayer, + id: remembered.id, }, - previous + remembered ); const restored = resolveOrigin( map, - previous, + remembered, candidateSetRef.current, - arrivalsRef.current + arrivalsRef.current, + originStrategy, + originMode ); if (restored) keepInView(map, restored.point, panDurationMs); return; @@ -614,7 +713,9 @@ export const FeatureKeyboardNav = ({ map, originFeatureRef.current, candidateSetNow, - arrivalsRef.current + arrivalsRef.current, + originStrategy, + originMode ); if (!origin) return; @@ -658,9 +759,10 @@ export const FeatureKeyboardNav = ({ // 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 = projected.find( - (entry) => entry.key === winnerKey - ); + const winnerOutline = + originMode === "static" + ? undefined + : projected.find((entry) => entry.key === winnerKey); const arrival = winnerOutline ? chordMidpoint( origin.point, @@ -679,12 +781,20 @@ export const FeatureKeyboardNav = ({ } const cameFrom = originFeatureRef.current; - // no entry for the bootstrap step: it started from the middle of the - // screen, and there is no feature to go back to - if (cameFrom) { - trail.push({ direction, from: cameFrom }); - if (trail.length > MAX_NAV_TRAIL) trail.shift(); + // 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 @@ -732,6 +842,8 @@ export const FeatureKeyboardNav = ({ libreMap, config, strategy, + originStrategy, + originMode, crossLayer, currentLayerBonus, minStepPx, @@ -868,7 +980,6 @@ export const FeatureKeyboardNav = ({ )} diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx index c1eb001ed2..14417fc409 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx @@ -3,7 +3,6 @@ import { createPortal } from "react-dom"; import type { Map as MaplibreMap } from "maplibre-gl"; import { - DEFAULT_ORIGIN_DOT_COLOR, DEFAULT_ORIGIN_DOT_OPACITY, } from "./constants"; import { rotate } from "./geometry"; @@ -318,15 +317,29 @@ export const ExplainOverlay = ({ * 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, - color = DEFAULT_ORIGIN_DOT_COLOR, opacity = DEFAULT_ORIGIN_DOT_OPACITY, }: { map: MaplibreMap | null; - origins: Array<[number, number]>; - color?: string; + origins: OriginDot[]; opacity?: number; }) => { useMapFrame(map); @@ -346,16 +359,17 @@ export const FeatureOriginDots = ({ }} data-test-id="feature-keyboard-nav-origins" > - {origins.map((lngLat, index) => { - const point = map.project(lngLat); + {origins.map((dot, index) => { + const point = map.project(dot.lngLat); + const filled = dot.filled ?? true; return ( ); 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 index f621bbd930..2324a20c32 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts @@ -62,6 +62,78 @@ const inputFor = ( 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); diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts index 6b26987082..9a634df305 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.ts @@ -33,83 +33,303 @@ const onFeaturePointOf = (geometry: Geometry): [number, number] | undefined => { } }; -export const interiorPointOf = ( - geometry: Geometry | null | undefined -): [number, number] | undefined => { - if (!geometry) 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 parts: Position[][][] = + 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 best: [number, number] | undefined; - let bestClearance = 0; - - for (const rings of parts) { - const outer = rings[0]; - if (!outer || outer.length < 4) continue; - - try { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const [x, y] of outer) { - if (x < minX) minX = x; - if (y < minY) minY = y; - if (x > maxX) maxX = x; - if (y > maxY) maxY = y; - } - const width = maxX - minX; - const height = maxY - minY; - // 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)) continue; - if (width <= 0 || height <= 0) continue; - - /** - * 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. On a MultiPolygon the roomiest part wins, so - // an island never steals the origin from the mainland. - if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue; - if (clearance > bestClearance) { - bestClearance = clearance; - best = [lng, lat]; - } - } catch { - // degenerate geometry (empty rings, NaN coordinates) from a broken tile + 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 best ?? onFeaturePointOf(geometry); + return pole?.point ?? onFeaturePointOf(geometry); }; /** Areas cast rays, everything else uses the cone. */ diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts index be413f58b9..a6b26568a4 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts @@ -1,5 +1,7 @@ import type { Positions } from "@carma-mapping/map-controls-layout"; +import type { OriginStrategy } from "./origin"; + /** * Types of the keyboard navigation addon. * @@ -74,6 +76,31 @@ export type FeatureKeyboardNavConfig = { 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, From 9777368058f76a6008d0ec90b3ace0c59473fa69 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 17:59:09 +0200 Subject: [PATCH 12/20] #749 rank the pressed direction above its fan in first-crossed The fan exists so a neighbour meeting the origin at a corner stays reachable, but all three rays were ranked by raw distance, so a parcel the outer ray merely clips beat the one lying straight ahead. Observed in the geoportal: a step left took the parcel diagonally below, clipped 132px out, over the one ahead at 141px. centerRayBonus multiplies the cost of a centre-ray hit, the way currentLayerBonus does for the current layer. Default 0.85, so the fan has to be about 15% nearer to win; 1 ranks all three alike and is the previous behaviour; 0 lets anything the centre ray touches win outright. This does not rescue every case: a ray that misses a feature cannot select it, so a neighbour the centre ray passes by is still only reachable through the fan, far out. Rejecting grazing hits by chord length is the next lever there. Tests use the observed geometry, one polygon ahead at 141px and one beside the axis the fan enters at ~126px: at 1 the clipped one wins, at 0.85 the one ahead does. The two spec fixtures gained the field; note that spec files are outside tsconfig.lib.json, so a missing PickInput member is not a type error there, and the code defaults it rather than computing NaN costs. --- .../src/app/constants/fachzwillinge/boden.ts | 6 +++ .../addons/src/addons/FeatureKeyboardNav.tsx | 4 ++ .../addons/feature-keyboard-nav/constants.ts | 2 + .../feature-keyboard-nav/origin.spec.ts | 1 + .../addons/feature-keyboard-nav/pick.spec.ts | 53 +++++++++++++++++++ .../src/addons/feature-keyboard-nav/pick.ts | 25 +++++++-- .../src/addons/feature-keyboard-nav/types.ts | 14 +++++ 7 files changed, 101 insertions(+), 4 deletions(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts index 148b6d9930..fe671ab0dc 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts @@ -65,6 +65,12 @@ export const bodenFachzwilling: FachzwillingRoute = { 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.85, // the mode is what this Fachzwilling is being built around right now, // so it starts switched on rather than one click away startActive: true, diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 7b53d21911..c2b34e1208 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -28,6 +28,7 @@ import { DEFAULT_CANDIDATE_DEBOUNCE_MS, DEFAULT_CROSS_LAYER, DEFAULT_CURRENT_LAYER_BONUS, + DEFAULT_CENTER_RAY_BONUS, DEFAULT_EDGE_BEHAVIOR, DEFAULT_EXPLAIN, DEFAULT_EXPLAIN_MS, @@ -382,6 +383,7 @@ export const FeatureKeyboardNav = ({ 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, @@ -740,6 +742,7 @@ export const FeatureKeyboardNav = ({ strategy, crossLayer, currentLayerBonus, + centerRayBonus, minStepPx, fanDeg, rayLengthPx: viewportDiagonalPx(map), @@ -846,6 +849,7 @@ export const FeatureKeyboardNav = ({ originMode, crossLayer, currentLayerBonus, + centerRayBonus, minStepPx, fanDeg, verifyWithRenderer, diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts index 08e273a29c..4c13e73a6b 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts @@ -34,6 +34,8 @@ 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; 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 index 2324a20c32..450efad748 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/origin.spec.ts @@ -57,6 +57,7 @@ const inputFor = ( strategy: "nearest-in-cone", crossLayer: "free", currentLayerBonus: 0.6, + centerRayBonus: 0.85, minStepPx: 0.001, fanDeg: 8, rayLengthPx: 100, 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 index 4cbf235434..e1a2ae50db 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.spec.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.spec.ts @@ -51,6 +51,7 @@ const inputFor = ( strategy: "nearest-in-cone", crossLayer: "free", currentLayerBonus: 0.6, + centerRayBonus: 0.85, minStepPx: 2, fanDeg: 8, rayLengthPx: 4000, @@ -218,3 +219,55 @@ describe("first-crossed on gap-free coverage (A5)", () => { 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 index 86c520215f..e8c27879eb 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/pick.ts @@ -187,10 +187,26 @@ export const evaluateFirstCrossed = (input: PickInput): FirstCrossedResult => { ); 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 } | undefined; + let best: + | { t: number; point: ScreenPoint; rayIndex: number; cost: number } + | undefined; for (let rayIndex = 0; rayIndex < directions.length; rayIndex++) { const crossing = firstCrossing( origin, @@ -203,9 +219,10 @@ export const evaluateFirstCrossed = (input: PickInput): FirstCrossedResult => { 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 || crossing.t < best.t) { - best = { ...crossing, rayIndex }; + if (!best || cost < best.cost) { + best = { ...crossing, rayIndex, cost }; } } @@ -217,7 +234,7 @@ export const evaluateFirstCrossed = (input: PickInput): FirstCrossedResult => { 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.t, + cost: best.cost, }); } diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts index a6b26568a4..157a4a5252 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts @@ -59,6 +59,18 @@ export type FeatureKeyboardNavConfig = { 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; @@ -208,6 +220,8 @@ export type PickInput = { 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 */ From c773f108fa5a7bf17675ca6d9f87daa71a6b3741 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 12 Aug 2026 18:40:13 +0200 Subject: [PATCH 13/20] #749 WIP: publish an addon selection into the store (handover, not working yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A selection published into the map selection context — arrow-key navigation publishes one per step — only got drawn as selected. The store kept the feature that was clicked last, so the infobox never followed the keyboard, and clicking that old feature again counted as a re-click and zoomed to it. Geoportal's selection flows one way, store -> context (the effect right above this one), because clicking used to be the only way to select. This adds the missing direction: a context selection the store did not make is built into an infobox feature and dispatched with setSelectedFeature, which is the route the main developer confirmed. Not working yet. createVectorFeature returns undefined for a published feature. Measured in the running app: the infobox mappings read properties that SelectionManager.enrichHits attaches to real click hits and that a feature from queryRenderedFeatures does not carry — the clicked feature has carmaInfo, the published one does not, and neither carries targetProperties. enrichSelectedFeature reproduces that enrichment, but the mapping still returns null, so something else is missing. Two console.info lines under [SELECTION_SYNC] are left in on purpose: one prints the resolved layer, the published feature's property keys and the layer's full infoboxMapping text, the other whether a feature was built. Reading the mapping text should name the missing property. Note firing a synthetic map click at the feature does make it work end to end — that is how gazetteer selections reach the infobox (GeoportalMap.onComplete) — but simulating clicks is not wanted here, so it is deliberately not in this commit. --- .../src/app/constants/fachzwillinge/boden.ts | 2 +- .../hooks/libre/useLibreMapClickHandler.ts | 152 +++++++++++++++++- 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts index fe671ab0dc..842a958900 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/boden.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/boden.ts @@ -70,7 +70,7 @@ export const bodenFachzwilling: FachzwillingRoute = { // 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.85, + 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, diff --git a/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts b/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts index 8c44f6e356..db4bceaa79 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,88 @@ 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; + } + + // a copy: the queried feature belongs to the addon that published it + return { ...feature, 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 +226,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 +449,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 From 02273360735508952d1b7435bf1704bb7fbc508c Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 12 Aug 2026 19:05:57 +0200 Subject: [PATCH 14/20] add missing geometry for selected features not created by clicks --- .../src/app/hooks/libre/useLibreMapClickHandler.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts b/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts index db4bceaa79..395852c634 100644 --- a/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts +++ b/apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts @@ -86,8 +86,11 @@ const enrichSelectedFeature = ( if (targetProps) properties.targetProperties = targetProps; } - // a copy: the queried feature belongs to the addon that published it - return { ...feature, properties } as maplibregl.MapGeoJSONFeature; + return { + ...feature, + geometry: feature.geometry, + properties, + } as maplibregl.MapGeoJSONFeature; }; /** From 189d49b9a15535e8c4ab064497d13826e52f8df5 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 12 Aug 2026 19:06:16 +0200 Subject: [PATCH 15/20] fix debug ui disappearing after new selection --- .../addons/src/addons/FeatureKeyboardNav.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index c2b34e1208..9e7ba5843f 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -459,9 +459,19 @@ export const FeatureKeyboardNav = ({ const [faded, setFaded] = useState(false); const explainIdRef = useRef(0); - /** The key the last step published, so the selection it causes can be told - * apart from one the user made. Consumed on arrival: selecting the same - * feature again by hand is a new selection and clears the picture too. */ + /** + * 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); /** @@ -502,7 +512,6 @@ export const FeatureKeyboardNav = ({ useEffect(() => { const selectedKey = rawFeature ? candidateKeyOf(rawFeature) : undefined; if (selectedKey !== undefined && selectedKey === navSelectedKeyRef.current) { - navSelectedKeyRef.current = undefined; return; } navSelectedKeyRef.current = undefined; From 3cf0f1e003f55f222ada06c7866d727ae440569a Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Fri, 14 Aug 2026 15:58:46 +0200 Subject: [PATCH 16/20] #749 center the bottomcenter control on the map, not among its siblings --- .../src/lib/components/control-styles.ts | 17 +++++++++++++++++ .../src/lib/map-control.module.css | 3 +++ 2 files changed, 20 insertions(+) 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; From 90b8dfed8241d2eed6012b37c0f13fdd9a7d4b9e Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Fri, 14 Aug 2026 15:58:46 +0200 Subject: [PATCH 17/20] #749 retry the candidate query while the set is still empty --- .../addons/feature-keyboard-nav/useNavCandidates.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts index 70a5774f67..b3f3f11335 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/useNavCandidates.ts @@ -83,6 +83,8 @@ export const useNavCandidates = ({ 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 } = @@ -109,7 +111,11 @@ export const useNavCandidates = ({ } const signature = viewSignature(mapInstance, navScopeKey(scopeRef.current)); - if (signature === signatureRef.current) return; + // 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[]; @@ -136,6 +142,7 @@ export const useNavCandidates = ({ requireCatalogLayer, maxCandidates: maxCandidatesRef.current, }); + lastCountRef.current = candidateSet.candidates.length; setState((previous) => ({ candidateSet, version: previous.version + 1, @@ -173,6 +180,7 @@ export const useNavCandidates = ({ // 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 From 20c3cf910c5fbab2fce43182a3a302896bbd75b7 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Fri, 14 Aug 2026 15:58:46 +0200 Subject: [PATCH 18/20] #749 keep the explain readout up for the whole navigation mode --- .../addons/src/addons/FeatureKeyboardNav.tsx | 7 +++- .../feature-keyboard-nav/ExplainOverlay.tsx | 34 ++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 9e7ba5843f..fd893055d9 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -997,12 +997,17 @@ export const FeatureKeyboardNav = ({ /> )} - {snapshot && ( + {/* the readout belongs to the mode, not to a keypress: on for as long as + the mode runs with `explain` on, and gone entirely when it is off */} + {isActive && explain !== "off" && ( )} diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx index 14417fc409..2f0225b2c7 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx @@ -6,7 +6,12 @@ import { DEFAULT_ORIGIN_DOT_OPACITY, } from "./constants"; import { rotate } from "./geometry"; -import type { PickExplanation, ScreenPoint } from "./types"; +import type { + NavStrategy, + PickExplanation, + ResolvedNavConstants, + ScreenPoint, +} from "./types"; /** * The helper geometry behind one decision, drawn on the map for about a second. @@ -391,13 +396,24 @@ export const ExplainLegend = ({ snapshot, faded, degraded = false, + strategy, + constants, + candidateCount, }: { 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; }) => { - if (!snapshot) return null; - const { explanation } = snapshot; + // 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} · θmax {format(explanation.coneAngleDeg)}° · w{" "} - {format(explanation.angleWeight)} · p {format(explanation.anglePower)} ·{" "} - {explanation.evaluations.length} Kandidaten + {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 && ( {" "} From b4cad6eb09fed794c203e65863814de218cc8403 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Fri, 14 Aug 2026 17:56:58 +0200 Subject: [PATCH 19/20] #749 let the legend switch the explain picture on and off The config value only seeds the picture mode now; the readout carries a switch that steps it through brief, hold and off while the mode runs. explain: "off" still keeps the readout and its switch away entirely. --- .../addons/src/addons/FeatureKeyboardNav.tsx | 44 ++++++++++++++++--- .../feature-keyboard-nav/ExplainOverlay.tsx | 34 ++++++++++++++ .../addons/feature-keyboard-nav/constants.ts | 25 ++++++++++- .../src/addons/feature-keyboard-nav/types.ts | 9 +++- 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index fd893055d9..1c00388b30 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -42,6 +42,7 @@ import { DEFAULT_VERIFY_MAX_RETRIES, EDGE_PAN_SETTLE_TIMEOUT_MS, KEEP_IN_VIEW_INSET_FRACTION, + nextExplainMode, resolveNavConstants, SHIFT_PAN_PX, } from "./feature-keyboard-nav/constants"; @@ -78,6 +79,7 @@ import { NAV_AXES, type FeatureKeyboardNavConfig, type NavDirection, + type NavExplainMode, type PickExplanation, type ScreenPoint, } from "./feature-keyboard-nav/types"; @@ -459,6 +461,30 @@ export const FeatureKeyboardNav = ({ 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. @@ -524,12 +550,12 @@ export const FeatureKeyboardNav = ({ const publishExplanation = useCallback( (map: MaplibreMap, explanation: PickExplanation) => { - if (explain === "off") return; + if (explainMode === "off") return; explainIdRef.current += 1; setFaded(false); setSnapshot(toExplainSnapshot(map, explanation, explainIdRef.current)); }, - [explain] + [explainMode] ); /** @@ -547,7 +573,7 @@ export const FeatureKeyboardNav = ({ */ const featureOrigins = useMemo( () => { - if (!showOrigins || explain === "off" || !rawFeature) return []; + 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); @@ -605,7 +631,7 @@ export const FeatureKeyboardNav = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [ showOrigins, - explain, + explainMode, rawFeature, selectionVersion, candidateSet, @@ -617,7 +643,7 @@ export const FeatureKeyboardNav = ({ useEffect(() => { - if (explain !== "brief" || !snapshot) return; + if (explainMode !== "brief" || !snapshot) return; const fade = setTimeout(() => setFaded(true), explainMs); const clear = setTimeout(() => setSnapshot(null), explainMs + FADE_MS); return () => { @@ -625,7 +651,7 @@ export const FeatureKeyboardNav = ({ clearTimeout(clear); }; // a new decision replaces the picture and restarts both timers - }, [snapshot, explain, explainMs]); + }, [snapshot, explainMode, explainMs]); useEffect(() => { if (isActive) return; @@ -998,7 +1024,9 @@ export const FeatureKeyboardNav = ({ )} {/* the readout belongs to the mode, not to a keypress: on for as long as - the mode runs with `explain` on, and gone entirely when it is off */} + 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" && ( )} diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx index 2f0225b2c7..5056ecec86 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/ExplainOverlay.tsx @@ -7,6 +7,7 @@ import { } from "./constants"; import { rotate } from "./geometry"; import type { + NavExplainMode, NavStrategy, PickExplanation, ResolvedNavConstants, @@ -111,6 +112,13 @@ const COLORS = { 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, @@ -399,6 +407,8 @@ export const ExplainLegend = ({ strategy, constants, candidateCount, + explainMode, + onCycleExplain, }: { snapshot: ExplainSnapshot | null; faded: boolean; @@ -409,6 +419,9 @@ export const ExplainLegend = ({ 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 @@ -442,6 +455,27 @@ export const ExplainLegend = ({ · 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/constants.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts index 4c13e73a6b..4b46768081 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/constants.ts @@ -1,4 +1,8 @@ -import type { FeatureKeyboardNavConfig, ResolvedNavConstants } from "./types"; +import type { + FeatureKeyboardNavConfig, + NavExplainMode, + ResolvedNavConstants, +} from "./types"; /** * The one knob and the three constants behind it. @@ -42,6 +46,25 @@ 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; diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts index 157a4a5252..c6e92651a4 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/types.ts @@ -84,7 +84,14 @@ export type FeatureKeyboardNavConfig = { /** Duration of keep-in-view and edge pans, in ms. Default: 300 */ panDurationMs?: number; - /** Helper geometry overlay. Default: "brief" */ + /** + * 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; From 2a2ef4962447358b67c130fcec8790f8b1b4fe0c Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Fri, 14 Aug 2026 18:37:32 +0200 Subject: [PATCH 20/20] #749 stop the walk from bouncing back into the feature it came from Two parcels sharing a long diagonal border each lie in the pressed direction from the other, so repeating a key ping-ponged between them with no way out. A repeated direction now excludes the feature one step back; the edge pan runs under the same rule and only then is the exclusion dropped, so a real dead end still selects. --- .../addons/src/addons/FeatureKeyboardNav.tsx | 64 +++++++++++++++---- .../addons/feature-keyboard-nav/projection.ts | 10 +++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx index 1c00388b30..ee8729b467 100644 --- a/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx +++ b/libraries/mapping/addons/src/addons/FeatureKeyboardNav.tsx @@ -742,9 +742,57 @@ export const FeatureKeyboardNav = ({ const axis = NAV_AXES[direction]; const constants = resolveNavConstants(config); - const maxAttempts = edgeBehavior === "pan" ? 2 : 1; - for (let attempt = 0; attempt < maxAttempts; attempt++) { + /** + * 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, @@ -765,6 +813,7 @@ export const FeatureKeyboardNav = ({ axis, coneAngleDeg: constants.coneAngleDeg, excludeKey: origin.key, + excludeKeys: pass.exclude, }); const { explanation } = pickInDirection({ @@ -863,17 +912,6 @@ export const FeatureKeyboardNav = ({ } return; } - - if (attempt + 1 >= maxAttempts) return; - const canvas = map.getCanvas(); - map.panBy( - [ - axis.x * canvas.clientWidth * panStepFraction, - axis.y * canvas.clientHeight * panStepFraction, - ], - { duration: panDurationMs } - ); - await waitForCandidates(); } }, [ diff --git a/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts b/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts index 9303037b0b..3626c8d77f 100644 --- a/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts +++ b/libraries/mapping/addons/src/addons/feature-keyboard-nav/projection.ts @@ -29,6 +29,7 @@ export const projectCandidates = ({ axis, coneAngleDeg, excludeKey, + excludeKeys, }: { map: MaplibreMap; candidates: NavCandidate[]; @@ -37,11 +38,20 @@ export const projectCandidates = ({ 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([