feat(geoportal-v2): on-map selection highlight
When the user clicks a parcel or building, render a subtle overlay so they can tell at a glance which feature corresponds to the open info panel. Four new MapLibre layers: v2-terenuri-selected-fill — green tint (#15803d/0.25) v2-terenuri-selected-line — darker green stroke (#14532d/2.5px) v2-cladiri-selected-fill — strong blue (#1d4ed8/0.55) v2-cladiri-selected-line — navy stroke (#0c2050/1.6px) All four start with a filter that matches nothing (==,object_id,-1). A new useEffect in MapViewer watches `selectedFeature` (passed down from GeoportalV2's `clicked` state) and updates the filter on the matching layer pair via map.setFilter on every change. Switching TERENURI ↔ CLADIRI clears the other layer pair, so the highlight never doubles up. selectedFeature.objectId is the filter key — comes straight from PMTiles' object_id property and is reliable across both layers. If the value isn't numeric (search-dropdown features sometimes lack it), the filter falls back to no-match — the panel still works, just no on-map glow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,7 @@ export function GeoportalV2() {
|
||||
ref={mapRef}
|
||||
basemap={basemap}
|
||||
onFeatureClick={handleFeatureClick}
|
||||
selectedFeature={clicked}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
|
||||
|
||||
@@ -96,11 +96,14 @@ export type MapViewerHandle = {
|
||||
interface Props {
|
||||
basemap: BasemapId;
|
||||
onFeatureClick: (f: ClickedFeatureLite | null) => void;
|
||||
/** Currently selected feature — drives the highlight overlay so the
|
||||
* user sees which parcel/building corresponds to the open info panel. */
|
||||
selectedFeature: ClickedFeatureLite | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
{ basemap, onFeatureClick, className },
|
||||
{ basemap, onFeatureClick, selectedFeature, className },
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -276,6 +279,54 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
},
|
||||
});
|
||||
|
||||
// ── Selection highlight ──
|
||||
// Subtle on-map feedback for whichever feature the info panel is
|
||||
// showing. Filter is keyed on object_id (more reliable than the
|
||||
// sometimes-missing uuid). Default filter matches nothing — gets
|
||||
// updated by the selectedFeature useEffect below on every panel
|
||||
// change.
|
||||
const NO_MATCH: maplibregl.FilterSpecification = [
|
||||
"==",
|
||||
["get", "object_id"],
|
||||
-1,
|
||||
];
|
||||
map.addLayer({
|
||||
id: "v2-terenuri-selected-fill",
|
||||
type: "fill",
|
||||
source: PM_SRC,
|
||||
"source-layer": "gis_terenuri",
|
||||
minzoom: 13,
|
||||
filter: NO_MATCH,
|
||||
paint: { "fill-color": "#15803d", "fill-opacity": 0.25 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "v2-terenuri-selected-line",
|
||||
type: "line",
|
||||
source: PM_SRC,
|
||||
"source-layer": "gis_terenuri",
|
||||
minzoom: 13,
|
||||
filter: NO_MATCH,
|
||||
paint: { "line-color": "#14532d", "line-width": 2.5 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "v2-cladiri-selected-fill",
|
||||
type: "fill",
|
||||
source: PM_SRC,
|
||||
"source-layer": "gis_cladiri",
|
||||
minzoom: 13,
|
||||
filter: NO_MATCH,
|
||||
paint: { "fill-color": "#1d4ed8", "fill-opacity": 0.55 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "v2-cladiri-selected-line",
|
||||
type: "line",
|
||||
source: PM_SRC,
|
||||
"source-layer": "gis_cladiri",
|
||||
minzoom: 13,
|
||||
filter: NO_MATCH,
|
||||
paint: { "line-color": "#0c2050", "line-width": 1.6 },
|
||||
});
|
||||
|
||||
// Building suffix label — "354686-C1" → "C1". Same logic eterra.live
|
||||
// uses: slice after the last dash. z16+ so we don't clutter the
|
||||
// overview when zoomed out.
|
||||
@@ -425,6 +476,49 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
[],
|
||||
);
|
||||
|
||||
// Sync the selection-highlight filters with the currently-clicked
|
||||
// feature. Filter on object_id (numeric, comes straight from PMTiles
|
||||
// properties). When nothing is selected — or when the basemap is
|
||||
// still loading — both filters fall back to "match nothing".
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !ready) return;
|
||||
const TERENURI_LAYERS = ["v2-terenuri-selected-fill", "v2-terenuri-selected-line"];
|
||||
const CLADIRI_LAYERS = ["v2-cladiri-selected-fill", "v2-cladiri-selected-line"];
|
||||
const NO_MATCH: maplibregl.FilterSpecification = ["==", ["get", "object_id"], -1];
|
||||
const setLayerFilters = (
|
||||
ids: string[],
|
||||
f: maplibregl.FilterSpecification,
|
||||
) => {
|
||||
for (const id of ids) {
|
||||
if (map.getLayer(id)) map.setFilter(id, f);
|
||||
}
|
||||
};
|
||||
if (!selectedFeature || !selectedFeature.objectId) {
|
||||
setLayerFilters(TERENURI_LAYERS, NO_MATCH);
|
||||
setLayerFilters(CLADIRI_LAYERS, NO_MATCH);
|
||||
return;
|
||||
}
|
||||
const objectIdNum = Number(selectedFeature.objectId);
|
||||
if (!Number.isFinite(objectIdNum)) {
|
||||
setLayerFilters(TERENURI_LAYERS, NO_MATCH);
|
||||
setLayerFilters(CLADIRI_LAYERS, NO_MATCH);
|
||||
return;
|
||||
}
|
||||
const matchFilter: maplibregl.FilterSpecification = [
|
||||
"==",
|
||||
["get", "object_id"],
|
||||
objectIdNum,
|
||||
];
|
||||
if (selectedFeature.layerId === "CLADIRI_ACTIVE") {
|
||||
setLayerFilters(TERENURI_LAYERS, NO_MATCH);
|
||||
setLayerFilters(CLADIRI_LAYERS, matchFilter);
|
||||
} else {
|
||||
setLayerFilters(TERENURI_LAYERS, matchFilter);
|
||||
setLayerFilters(CLADIRI_LAYERS, NO_MATCH);
|
||||
}
|
||||
}, [selectedFeature, ready]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
Reference in New Issue
Block a user