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:
Claude VM
2026-05-20 09:10:39 +03:00
parent 8d5316dd1b
commit 7b01744fad
2 changed files with 96 additions and 1 deletions
@@ -60,6 +60,7 @@ export function GeoportalV2() {
ref={mapRef} ref={mapRef}
basemap={basemap} basemap={basemap}
onFeatureClick={handleFeatureClick} onFeatureClick={handleFeatureClick}
selectedFeature={clicked}
className="h-full w-full" className="h-full w-full"
/> />
+95 -1
View File
@@ -96,11 +96,14 @@ export type MapViewerHandle = {
interface Props { interface Props {
basemap: BasemapId; basemap: BasemapId;
onFeatureClick: (f: ClickedFeatureLite | null) => void; 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; className?: string;
} }
export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer( export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
{ basemap, onFeatureClick, className }, { basemap, onFeatureClick, selectedFeature, className },
ref, ref,
) { ) {
const containerRef = useRef<HTMLDivElement>(null); 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 // Building suffix label — "354686-C1" → "C1". Same logic eterra.live
// uses: slice after the last dash. z16+ so we don't clutter the // uses: slice after the last dash. z16+ so we don't clutter the
// overview when zoomed out. // 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 ( return (
<div <div
ref={containerRef} ref={containerRef}