Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d39b60d
#749 add feature keyboard nav addon
PavelOlkhovoi Aug 11, 2026
800c5d0
#749 fix position of pick explanation component
PavelOlkhovoi Aug 11, 2026
0892139
#749 add all origins to explain mode
PavelOlkhovoi Aug 12, 2026
38842c9
#749 change interior point function
PavelOlkhovoi Aug 12, 2026
53e25ec
#749 draw the interior point only for the selected feature
helllth Aug 12, 2026
497610f
#749 clear the explain picture on a hand-made selection, and walk the…
helllth Aug 12, 2026
e7dcb04
#749 scale polylabel precision by the narrow side of the polygon
helllth Aug 12, 2026
8acde0c
#749 rebuild the candidate set only when the view changed
helllth Aug 12, 2026
24101c2
#749 measure from where the walk arrived, over the whole feature
helllth Aug 12, 2026
3bb380d
#749 start the Boden Fachzwilling in navigation mode
helllth Aug 12, 2026
dcdc00f
#749 pick the origin by strategy, and replay the walk in both directions
helllth Aug 12, 2026
9777368
#749 rank the pressed direction above its fan in first-crossed
helllth Aug 12, 2026
c773f10
#749 WIP: publish an addon selection into the store (handover, not wo…
helllth Aug 12, 2026
0227336
add missing geometry for selected features not created by clicks
d4v3000 Aug 12, 2026
189d49b
fix debug ui disappearing after new selection
d4v3000 Aug 12, 2026
3cf0f1e
#749 center the bottomcenter control on the map, not among its siblings
helllth Aug 14, 2026
90b8dfe
#749 retry the candidate query while the set is still empty
helllth Aug 14, 2026
20c3cf9
#749 keep the explain readout up for the whole navigation mode
helllth Aug 14, 2026
b4cad6e
#749 let the legend switch the explain picture on and off
helllth Aug 14, 2026
2a2ef49
#749 stop the walk from bouncing back into the feature it came from
helllth Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/geoportal/src/app/components/layers/InteractionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<TargetAddonHost addon={groupAddon} target={group} />
) : layer && layerAddon ? (
<TargetAddonHost addon={layerAddon} target={layer} />
) : layer && (hasInteractionComponent || showFilter) ? (
<InteractionContent layer={layer} />
) : null;
Expand Down
35 changes: 35 additions & 0 deletions apps/geoportal/src/app/constants/fachzwillinge/boden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
15 changes: 14 additions & 1 deletion apps/geoportal/src/app/constants/fachzwillinge/gesundheit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
155 changes: 153 additions & 2 deletions apps/geoportal/src/app/hooks/libre/useLibreMapClickHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown> = {
...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 = {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions libraries/mapping/addons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -84,6 +85,16 @@ two libraries circular. Declaration sites keep full kind checking by narrowing t
`WorkflowPerspective<TTool>` type parameter, as the geoportal does with
`WorkflowPerspective<AddonEntry>`.

### 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
Expand Down
7 changes: 7 additions & 0 deletions libraries/mapping/addons/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libraries/mapping/addons"
}
}
}
}
Loading
Loading