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