diff --git a/map/AGENTS.md b/map/AGENTS.md
new file mode 120000
index 000000000..681311eb9
--- /dev/null
+++ b/map/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/map/src/infoblock/components/wpt/WptTagsProvider.js b/map/src/infoblock/components/wpt/WptTagsProvider.js
index 6722f3600..63c34d468 100644
--- a/map/src/infoblock/components/wpt/WptTagsProvider.js
+++ b/map/src/infoblock/components/wpt/WptTagsProvider.js
@@ -86,6 +86,7 @@ export const CITY = WEB_PREFIX + 'city';
export const ADDRESS_1 = WEB_PREFIX + 'address1';
export const ADDRESS_2 = WEB_PREFIX + 'address2';
export const MATCHED_OBJECTS = WEB_PREFIX + 'matched_objects';
+export const BBOX_LAT_LON = WEB_PREFIX + 'bbox_lat_lon';
export const CATEGORY_KEY_NAME = WEB_PREFIX + 'keyName';
export const ICON_KEY_NAME = WEB_PREFIX + 'iconKeyName';
export const TYPE_OSM_TAG = WEB_PREFIX + 'typeOsmTag';
diff --git a/map/src/manager/SpatialSearchMatchedObjects.js b/map/src/manager/SpatialSearchMatchedObjects.js
new file mode 100644
index 000000000..96484b51c
--- /dev/null
+++ b/map/src/manager/SpatialSearchMatchedObjects.js
@@ -0,0 +1,166 @@
+import {
+ BBOX_LAT_LON,
+ CATEGORY_NAME,
+ CATEGORY_TYPE,
+ MATCHED_OBJECTS,
+ POI_ID,
+ POI_NAME,
+} from '../infoblock/components/wpt/WptTagsProvider';
+
+export const MATCHED_OBJECT_TYPE_AMENITY = 'Amenity';
+export const MATCHED_OBJECT_TYPE_CITY = 'City';
+export const MATCHED_OBJECT_TYPE_STREET = 'Street';
+
+const DEFAULT_MATCHED_AMENITY_CATEGORY_TYPE = 'POI';
+
+export function getAdditionalMatchedAmenityObjects(matchedObjects) {
+ return matchedObjects?.length > 1
+ ? matchedObjects.slice(1).filter((obj) => obj?.type === MATCHED_OBJECT_TYPE_AMENITY)
+ : [];
+}
+
+export function getFirstMatchedPoiTypeLocationObject(matchedObjects) {
+ return (
+ matchedObjects?.find(
+ (obj) =>
+ obj?.type === MATCHED_OBJECT_TYPE_CITY ||
+ obj?.type === MATCHED_OBJECT_TYPE_STREET ||
+ obj?.type === MATCHED_OBJECT_TYPE_AMENITY
+ ) ?? null
+ );
+}
+
+export function getMatchedAmenityProperties(obj, defaultCategoryType = DEFAULT_MATCHED_AMENITY_CATEGORY_TYPE) {
+ return {
+ ...obj,
+ [CATEGORY_TYPE]: obj[CATEGORY_TYPE] ?? defaultCategoryType,
+ [POI_NAME]: obj[POI_NAME] ?? obj.name ?? '',
+ name: obj.name ?? obj[POI_NAME],
+ };
+}
+
+export function getMatchedObjectName(obj) {
+ return obj?.name ?? obj?.[CATEGORY_NAME] ?? obj?.[POI_NAME];
+}
+
+export function hasValidMatchedObjectCoords(obj) {
+ return Number.isFinite(obj?.lat) && Number.isFinite(obj?.lon);
+}
+
+export function createSearchMatchedObjectActions({
+ item,
+ t,
+ ctx,
+ navigate,
+ recentSaver,
+ setShowMatched,
+ formatSearchResultProperties,
+ navigateToPoi,
+ objectSearchType,
+ poiObjectsKey,
+ poiTypeCategory,
+}) {
+ const matchedObjects = item.properties?.[MATCHED_OBJECTS] ?? [];
+ const isPoiTypeResult = item.properties?.[CATEGORY_TYPE] === poiTypeCategory;
+ const matchedAmenityObjects = isPoiTypeResult ? [] : getAdditionalMatchedAmenityObjects(matchedObjects);
+ const matchedPoiTypeLocationObject = isPoiTypeResult ? getFirstMatchedPoiTypeLocationObject(matchedObjects) : null;
+
+ function openMatchedObject(obj) {
+ if (!hasValidMatchedObjectCoords(obj)) {
+ return;
+ }
+ ctx.setZoomToCoords({ lat: obj.lat, lon: obj.lon, bbox: obj[BBOX_LAT_LON] });
+ setShowMatched(false);
+ }
+
+ function openMatchedAmenity(obj) {
+ if (!hasValidMatchedObjectCoords(obj)) {
+ return;
+ }
+
+ const options = getMatchedAmenityProperties(obj);
+ const id = obj[POI_ID] ?? `${obj.lat},${obj.lon}`;
+ const poi = {
+ key: id,
+ options,
+ latlng: { lat: obj.lat, lng: obj.lon },
+ };
+
+ ctx.setCurrentObjectType(objectSearchType);
+ ctx.setSelectedPoiObj({ ...poi });
+ ctx.setSelectedWpt({ poi, id });
+ recentSaver(poiObjectsKey, poi);
+ ctx.setMoveToMapObj({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: [obj.lon, obj.lat] },
+ properties: options,
+ });
+ navigateToPoi({ poi }, navigate);
+ }
+
+ function getMatchedAmenityName(obj) {
+ return formatSearchResultProperties(getMatchedAmenityProperties(obj), t).name;
+ }
+
+ function moveToMatchedPoiTypeLocation() {
+ openMatchedObject(matchedPoiTypeLocationObject);
+ }
+
+ const matchedNameObjects = [
+ ...matchedAmenityObjects.map((obj, index) =>
+ createMatchedNameObject({
+ obj,
+ index,
+ name: getMatchedAmenityName(obj),
+ openMatchedObject: openMatchedAmenity,
+ })
+ ),
+ ...(matchedPoiTypeLocationObject
+ ? [
+ createMatchedNameObject({
+ obj: matchedPoiTypeLocationObject,
+ index: matchedAmenityObjects.length,
+ name: getMatchedObjectName(matchedPoiTypeLocationObject),
+ openMatchedObject,
+ }),
+ ]
+ : []),
+ ].filter(({ name }) => name);
+
+ const matchedDialogObjects = matchedObjects.map((obj, index) => ({
+ key: getMatchedObjectKey(obj, index),
+ obj,
+ onClick: () => openMatchedObject(obj),
+ }));
+
+ return {
+ matchedObjects,
+ matchedNameObjects,
+ matchedDialogObjects,
+ moveToMatchedPoiTypeLocation,
+ };
+}
+
+function createMatchedNameObject({ obj, index, name, openMatchedObject }) {
+ const onClick = (event) => {
+ event.stopPropagation();
+ openMatchedObject(obj);
+ };
+
+ return {
+ key: getMatchedObjectKey(obj, index),
+ obj,
+ name,
+ onClick,
+ onKeyDown: (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onClick(event);
+ }
+ },
+ };
+}
+
+function getMatchedObjectKey(obj, index = 0) {
+ return obj[POI_ID] ?? `${obj.type}-${obj.lat}-${obj.lon}-${index}`;
+}
diff --git a/map/src/map/layers/SearchLayer.js b/map/src/map/layers/SearchLayer.js
index 893a2370c..d9bab5077 100644
--- a/map/src/map/layers/SearchLayer.js
+++ b/map/src/map/layers/SearchLayer.js
@@ -15,10 +15,12 @@ import { useMap } from 'react-leaflet';
import { getPoiIcon } from './PoiLayer';
import L from 'leaflet';
import {
+ BBOX_LAT_LON,
CATEGORY_NAME,
CATEGORY_TYPE,
FINAL_POI_ICON_NAME,
ICON_KEY_NAME,
+ MATCHED_OBJECTS,
POI_ICON_NAME,
POI_ID,
POI_NAME,
@@ -50,6 +52,12 @@ import { hideMarkersNearPin } from '../util/MarkerSelectionService';
import { POI_OBJECTS_KEY, useRecentDataSaver } from '../../util/hooks/menu/useRecentDataSaver';
import { useNavigate } from 'react-router-dom';
import { getCurrentTimeParams } from '../../util/Utils';
+import { fitBoundsOptions } from '../../manager/track/TracksManager';
+import {
+ getAdditionalMatchedAmenityObjects,
+ getMatchedAmenityProperties,
+ hasValidMatchedObjectCoords,
+} from '../../manager/SpatialSearchMatchedObjects';
export const SEARCH_TYPE_CATEGORY = 'category';
@@ -109,6 +117,31 @@ export function buildFavGroupMap(favoriteFeatures) {
return result.size > 0 ? result : null;
}
+function getBboxLatLngBounds(bbox) {
+ if (!bbox) return null;
+ const top = Number(bbox.top);
+ const left = Number(bbox.left);
+ const bottom = Number(bbox.bottom);
+ const right = Number(bbox.right);
+ if (![top, left, bottom, right].every(Number.isFinite)) return null;
+
+ return L.latLngBounds([
+ [bottom, left],
+ [top, right],
+ ]);
+}
+
+function fitBboxIfValid({ map, mtx, bbox }) {
+ const bounds = getBboxLatLngBounds(bbox);
+ if (!map || !bounds?.isValid()) {
+ return false;
+ }
+
+ map.fitBounds(bounds, fitBoundsOptions(mtx));
+
+ return true;
+}
+
export default function SearchLayer() {
const ctx = useContext(AppContext);
const mtx = useContext(MapContext);
@@ -138,7 +171,9 @@ export default function SearchLayer() {
useEffect(() => {
if (ctx.zoomToCoords) {
- panToIfNeeded({ map, latlng: { lat: ctx.zoomToCoords.lat, lon: ctx.zoomToCoords.lon }, ctx });
+ if (!fitBboxIfValid({ map, mtx, bbox: ctx.zoomToCoords.bbox })) {
+ panToIfNeeded({ map, latlng: { lat: ctx.zoomToCoords.lat, lon: ctx.zoomToCoords.lon }, ctx });
+ }
ctx.setZoomToCoords(null);
}
}, [ctx.zoomToCoords]);
@@ -149,7 +184,7 @@ export default function SearchLayer() {
if (oldPoiLayer) {
map.removeLayer(oldPoiLayer);
}
- if (ctx.searchQuery) {
+ if (ctx.searchQuery?.query || ctx.searchQuery?.type) {
ctx.setShowPoiCategories([]);
if (ctx.searchQuery.type) {
searchByCategory(ctx.searchQuery);
@@ -168,7 +203,9 @@ export default function SearchLayer() {
// When favorites change (rename, edit, delete), refresh the favorites part of search results
useEffect(() => {
const query = ctx.searchQuery?.query;
- if (!query || ctx.searchQuery?.type || !ctx.searchResult) return;
+ if (!query || ctx.searchQuery?.type || !ctx.searchResult) {
+ return;
+ }
const favoriteFeatures = searchFavoriteFeatures({
favorites: ctx.favorites,
@@ -236,7 +273,9 @@ export default function SearchLayer() {
pushMapView({ map, mtx, key: MAP_VIEW_SEARCH_RESULT });
}
const [lng, lat] = ctx.moveToMapObj.geometry.coordinates;
- panToIfNeeded({ map, latlng: { lat, lng }, ctx });
+ if (!fitBboxIfValid({ map, mtx, bbox: ctx.moveToMapObj.properties?.[BBOX_LAT_LON] })) {
+ panToIfNeeded({ map, latlng: { lat, lng }, ctx });
+ }
ctx.setMoveToMapObj(null);
}
}, [ctx.moveToMapObj]);
@@ -347,7 +386,11 @@ export default function SearchLayer() {
async function createSearchLayer({ objList }) {
const visibleObjList = filterByVisibleBounds(objList, getVisibleBboxInfo(ctx, map)?.bounds);
- const innerCache = await createPoiCache({ poiList: visibleObjList, poiIconCache: ctx.poiIconCache });
+ const searchMarkerFeatures = createSearchMarkerFeatures(visibleObjList);
+ const innerCache = await createPoiCache({
+ poiList: searchMarkerFeatures,
+ poiIconCache: ctx.poiIconCache,
+ });
updatePoiCache(ctx, innerCache);
const center = map.getCenter();
@@ -355,7 +398,9 @@ export default function SearchLayer() {
const latitude = center.lat;
// FAVORITE and GPX_TRACK are user objects rendered by their own layers — skip map markers for them.
const USER_OBJECT_TYPES = new Set([searchTypeMap.FAVORITE, searchTypeMap.GPX_TRACK]);
- const mapMarkerFeatures = visibleObjList.filter((f) => !USER_OBJECT_TYPES.has(f.properties?.[CATEGORY_TYPE]));
+ const mapMarkerFeatures = searchMarkerFeatures.filter(
+ (f) => !USER_OBJECT_TYPES.has(f.properties?.[CATEGORY_TYPE])
+ );
const { mainMarkers, secondaryMarkers } = clusterMarkers({
places: mapMarkerFeatures,
@@ -493,6 +538,74 @@ function filterByVisibleLevel(features, spatialSearch, visibleLevel) {
return (features ?? []).filter((f) => (f?.properties?.[WEB_VISIBLE_LEVEL] ?? 0) <= visibleLevel);
}
+function createSearchMarkerFeatures(features) {
+ const markerFeatures = (features ?? []).map((feature) => ({
+ ...feature,
+ properties: { ...(feature.properties ?? {}) },
+ }));
+ const featureByKey = new Map();
+
+ markerFeatures.forEach((feature) => {
+ const key = getFeatureKey(feature);
+ if (key) {
+ featureByKey.set(key, feature);
+ }
+ });
+
+ markerFeatures.forEach((feature) => {
+ const resultId = getObjIdSearch(feature);
+ getAdditionalMatchedAmenityObjects(feature?.properties?.[MATCHED_OBJECTS]).forEach((obj) => {
+ if (!hasValidMatchedObjectCoords(obj)) {
+ return;
+ }
+
+ const key = getMatchedAmenityKey(obj);
+ const existing = featureByKey.get(key);
+ if (existing) {
+ addRelatedResultId(existing.properties, resultId);
+ return;
+ }
+
+ const matchedFeature = {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [obj.lon, obj.lat],
+ },
+ properties: {
+ ...getMatchedAmenityProperties(obj, searchTypeMap.POI),
+ },
+ };
+ addRelatedResultId(matchedFeature.properties, resultId);
+ featureByKey.set(key, matchedFeature);
+ markerFeatures.push(matchedFeature);
+ });
+ });
+
+ return markerFeatures;
+}
+
+function getFeatureKey(feature) {
+ const coord = feature.geometry.coordinates;
+ const name = feature.properties?.[POI_NAME] ?? feature.properties?.name ?? feature.properties?.[CATEGORY_NAME];
+ return feature?.properties?.[POI_ID] ?? formatSearchMarkerKey(coord[1], coord[0], name);
+}
+
+function getMatchedAmenityKey(obj) {
+ return obj[POI_ID] ?? formatSearchMarkerKey(obj.lat, obj.lon, obj[POI_NAME] ?? obj.name);
+}
+
+function formatSearchMarkerKey(lat, lon, name) {
+ return `${lat.toFixed(6)},${lon.toFixed(6)}:${name ?? ''}`;
+}
+
+function addRelatedResultId(properties, resultId) {
+ if (resultId == null) return;
+ const ids = new Set(properties.relatedResultIds ?? []);
+ ids.add(resultId);
+ properties.relatedResultIds = Array.from(ids);
+}
+
function filterByVisibleBounds(features, bounds) {
if (!bounds) return features ?? [];
diff --git a/map/src/map/util/Clusterizer.js b/map/src/map/util/Clusterizer.js
index e469c3897..b6b57af69 100644
--- a/map/src/map/util/Clusterizer.js
+++ b/map/src/map/util/Clusterizer.js
@@ -385,7 +385,15 @@ export function addMarkerTooltip({
marker.on('mouseover', () => {
removeTooltip(map, tooltipRef);
- setSelectedId?.({ id: marker.options.idObj, show: true, type, hoverFromMap: true });
+ const relatedResultIds = (marker.options.relatedResultIds ?? []).filter((id) => id != null);
+ setSelectedId?.({
+ id: marker.options.idObj,
+ relatedResultIds,
+ show: true,
+ type,
+ hoverFromMap: true,
+ hoverLatlng: marker.getLatLng?.(),
+ });
if (text) {
const offset = mainStyle ? [5, iconSize * 0.8] : [0, iconSize * 0.8];
tooltipRef.current = createTooltip(Utils.truncateText(text, TOOLTIP_MAX_LENGTH), latlng, { offset });
@@ -395,7 +403,9 @@ export function addMarkerTooltip({
marker.on('mouseout', (event) => {
if (event.originalEvent) {
- if (!mainStyle && marker.options.selected) return;
+ if (!mainStyle && marker.options.selected) {
+ return;
+ }
removeTooltip(map, tooltipRef);
setSelectedId?.({ id: -1, show: false, type });
}
diff --git a/map/src/map/util/MarkerSelectionService.js b/map/src/map/util/MarkerSelectionService.js
index baa29c0ef..734e11a24 100644
--- a/map/src/map/util/MarkerSelectionService.js
+++ b/map/src/map/util/MarkerSelectionService.js
@@ -141,6 +141,31 @@ export function restoreOriginalIcon(layer) {
}
}
+function restoreUpdatedLayers(updatedLayersRef) {
+ const current = updatedLayersRef?.current;
+ const layers = Array.isArray(current) ? current : current ? [current] : [];
+ layers.forEach(restoreOriginalIcon);
+ if (updatedLayersRef) {
+ updatedLayersRef.current = null;
+ }
+}
+
+function clearHoverMarkers(ctx, map) {
+ restoreUpdatedLayers(ctx.selectedUpdatedLayerRef);
+ if (ctx.selectedCreatedLayerRef?.current && map.hasLayer(ctx.selectedCreatedLayerRef.current)) {
+ map.removeLayer(ctx.selectedCreatedLayerRef.current);
+ ctx.selectedCreatedLayerRef.current = null;
+ }
+}
+
+function applyHoverUpdateMarker(map, layer, markerData) {
+ if (!layer || !markerData || !map.hasLayer(layer)) {
+ return null;
+ }
+ applySelectedWithUpdateMarker(layer, markerData);
+ return layer;
+}
+
// Shows an outline ring around a point hovered on the map
export function applyHoverOutline({ ctx, map, layer = null, latlng = null, shape, color, size }) {
if (!ctx || !map) {
@@ -224,7 +249,9 @@ export function applyDirectionPin({ ctx, map, latlng, markerData }) {
// Removes the current selected/hover pin and restores hidden markers.
// The created pin is kept only if its idObj still matches the current selectedWpt id.
export function resetSelectedPin({ ctx, map, force = false }) {
- if (!ctx || !map) return;
+ if (!ctx || !map) {
+ return;
+ }
restoreHiddenMarkers(ctx.selectedHiddenLayersRef);
@@ -237,17 +264,38 @@ export function resetSelectedPin({ ctx, map, force = false }) {
}
}
- if (ctx.selectedUpdatedLayerRef?.current) {
- restoreOriginalIcon(ctx.selectedUpdatedLayerRef.current);
+ restoreUpdatedLayers(ctx.selectedUpdatedLayerRef);
+}
+
+export function applySelectedPins({ ctx, map, items }) {
+ if (!ctx || !map || !items?.length) {
+ return [];
+ }
+
+ clearHoverMarkers(ctx, map);
+
+ const selectedLayers = items
+ .map(({ layer, markerData }) => applyHoverUpdateMarker(map, layer, markerData))
+ .filter(Boolean);
+
+ if (!selectedLayers.length) {
ctx.selectedUpdatedLayerRef.current = null;
+ return [];
}
+
+ ctx.selectedUpdatedLayerRef.current = selectedLayers;
+ updateMarkerZIndex(new L.FeatureGroup(selectedLayers), SELECTED_MARKER_Z_INDEX);
+
+ return selectedLayers;
}
// Main entry point for showing a selected or hovered pin.
// isSelection=true: creates a new separate pin on top of the map and hides nearby markers.
// isSelection=false (hover): updates the existing marker icon in-place.
export function applySelectedPin({ ctx, map, layer = null, latlng = null, markerData, isSelection = false }) {
- if (!ctx || !map || !markerData) return null;
+ if (!ctx || !map || !markerData) {
+ return null;
+ }
const ll = latlng ?? layer?.getLatLng();
if (!ll) return null;
@@ -256,15 +304,7 @@ export function applySelectedPin({ ctx, map, layer = null, latlng = null, marker
resetSelectedPin({ ctx, map, force: true });
ctx.selectedHiddenLayersRef.current = [];
} else {
- // On hover: restore any previously updated icon and remove any previously created hover pin.
- if (ctx.selectedUpdatedLayerRef?.current) {
- restoreOriginalIcon(ctx.selectedUpdatedLayerRef.current);
- ctx.selectedUpdatedLayerRef.current = null;
- }
- if (ctx.selectedCreatedLayerRef?.current && map.hasLayer(ctx.selectedCreatedLayerRef.current)) {
- map.removeLayer(ctx.selectedCreatedLayerRef.current);
- ctx.selectedCreatedLayerRef.current = null;
- }
+ clearHoverMarkers(ctx, map);
}
let selectedLayer;
@@ -277,9 +317,8 @@ export function applySelectedPin({ ctx, map, layer = null, latlng = null, marker
selectedLayer.getElement().setAttribute('id', `se-selected-marker-${layer?.options?.name}-${markerColor}`);
hideMarkersNearPin(map, ctx);
} else if (layer && map.hasLayer(layer)) {
- applySelectedWithUpdateMarker(layer, markerData);
- ctx.selectedUpdatedLayerRef.current = layer;
- selectedLayer = layer;
+ selectedLayer = applyHoverUpdateMarker(map, layer, markerData);
+ ctx.selectedUpdatedLayerRef.current = selectedLayer;
} else {
// Fallback: layer is not on the map — create a new pin at the given latlng.
selectedLayer = applySelectedWithCreateMarker(map, ll, markerData, layer?.options);
diff --git a/map/src/menu/search/search.module.css b/map/src/menu/search/search.module.css
index 6ac563991..542b61528 100644
--- a/map/src/menu/search/search.module.css
+++ b/map/src/menu/search/search.module.css
@@ -99,6 +99,16 @@
cursor: pointer;
margin-left: 6px;
}
+.matchedObjectName {
+ color: inherit;
+ cursor: pointer;
+ font-size: 14px !important;
+ font-weight: 400 !important;
+ text-decoration: none;
+}
+.matchedObjectName:hover {
+ text-decoration: underline;
+}
.matchedItem {
padding-left: 24px !important;
}
@@ -121,6 +131,9 @@
font-style: normal !important;
font-weight: 400 !important;
}
+.titleText:hover {
+ text-decoration: underline;
+}
.placeItem {
display: flex !important;
padding: 16px !important;
diff --git a/map/src/menu/search/search/CustomInput.jsx b/map/src/menu/search/search/CustomInput.jsx
index 6b6c2d133..50a0157df 100644
--- a/map/src/menu/search/search/CustomInput.jsx
+++ b/map/src/menu/search/search/CustomInput.jsx
@@ -39,26 +39,7 @@ export default function CustomInput({
useEffect(() => {
if (!isInitialRender) {
if (value === EMPTY_SEARCH) {
- if (spatialSearchTimerRef.current) {
- clearTimeout(spatialSearchTimerRef.current);
- spatialSearchTimerRef.current = null;
- }
- ctx.setSearchResult((prevResult) => {
- return {
- ...prevResult,
- features: [],
- };
- });
- if (setSearchValue) {
- setSearchValue(null);
- } else {
- ctx.setSearchQuery((prev) => ({
- ...prev,
- query: '',
- type: null,
- }));
- navigateToSearchResults({ query: '', type: null });
- }
+ clearSearch();
}
} else {
setIsInitialRender(false);
@@ -114,6 +95,23 @@ export default function CustomInput({
navigateToSearchResults({ query: value });
}
+ function clearSearch() {
+ if (spatialSearchTimerRef.current) {
+ clearTimeout(spatialSearchTimerRef.current);
+ spatialSearchTimerRef.current = null;
+ }
+ ctx.setSearchResult((prevResult) => ({
+ ...prevResult,
+ features: [],
+ }));
+ if (setSearchValue) {
+ setSearchValue(null);
+ } else {
+ ctx.setSearchQuery({ query: '', type: null });
+ navigateToSearchResults({ query: '', type: null });
+ }
+ }
+
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -173,6 +171,7 @@ export default function CustomInput({
className={`${gStyles.icon} ${styles.searchInputIcon} ${isFocused ? styles.focusedIcon : ''}`}
onClick={() => {
setValue(EMPTY_SEARCH);
+ inputRef.current?.focus();
}}
>
diff --git a/map/src/menu/search/search/SearchResultItem.jsx b/map/src/menu/search/search/SearchResultItem.jsx
index c978d5c18..96fb9f9d1 100644
--- a/map/src/menu/search/search/SearchResultItem.jsx
+++ b/map/src/menu/search/search/SearchResultItem.jsx
@@ -20,7 +20,8 @@ import { useTranslation } from 'react-i18next';
import capitalize from 'lodash-es/capitalize';
import { formattingPoiType, navigateToPoi } from '../../../manager/PoiManager';
import AppContext, { OBJECT_SEARCH, OBJECT_TYPE_CLOUD_TRACK, OBJECT_TYPE_POI } from '../../../context/AppContext';
-import { getObjIdSearch, searchTypeMap, FAVORITE_HIT_GROUP_ID } from '../../../map/layers/SearchLayer';
+import { FAVORITE_HIT_GROUP_ID, getObjIdSearch, searchTypeMap } from '../../../map/layers/SearchLayer';
+import { createSearchMatchedObjectActions } from '../../../manager/SpatialSearchMatchedObjects';
import DistanceInfo from '../../../infoblock/components/common/DistanceInfo';
import { getDistance, getBearing } from '../../../util/Utils';
import {
@@ -31,7 +32,6 @@ import {
CITY,
EN_NAME,
MAIN_CATEGORY_KEY_NAME,
- MATCHED_OBJECTS,
POI_NAME,
POI_SUBTYPE,
POI_TYPE,
@@ -137,8 +137,12 @@ export function getPropsFromSearchResultItem(props, t = null, lang = null, listF
}
function getTrackInfo(name, listFiles, unitsSettings, t) {
- if (!listFiles || !unitsSettings) return '';
+ if (!listFiles || !unitsSettings) {
+ return '';
+ }
+
const file = listFiles.uniqueFiles?.find((f) => f.name === name);
+
return getTrackInfoText(file, unitsSettings, t);
}
@@ -158,16 +162,24 @@ export default function SearchResultItem({ item, typeItem, index, currentLoc, lo
const [showMatched, setShowMatched] = useState(false);
const [showPropertiesDump, setShowPropertiesDump] = useState(false);
- const matchedObjects = item.properties?.[MATCHED_OBJECTS] ?? [];
- const showPropertiesDumpIcon = (!ctx.searchQuery?.type && ctx.spatialSearch) || ctx.develFeatures;
- function openMatchedObject(obj) {
- ctx.setZoomToCoords({ lat: obj.lat, lon: obj.lon });
- setShowMatched(false);
- }
-
const { navigateToSearchResults } = useSearchNav();
const recentSaver = useRecentDataSaver();
const backToSearchResultsState = { state: { backToSearchResults: true } };
+ const { matchedObjects, matchedNameObjects, matchedDialogObjects, moveToMatchedPoiTypeLocation } =
+ createSearchMatchedObjectActions({
+ item,
+ t,
+ ctx,
+ navigate,
+ recentSaver,
+ setShowMatched,
+ formatSearchResultProperties: getPropsFromSearchResultItem,
+ navigateToPoi,
+ objectSearchType: OBJECT_SEARCH,
+ poiObjectsKey: POI_OBJECTS_KEY,
+ poiTypeCategory: searchTypeMap.POI_TYPE,
+ });
+ const showPropertiesDumpIcon = (!ctx.searchQuery?.type && ctx.spatialSearch) || ctx.develFeatures;
const itemId = getObjIdSearch(item);
@@ -190,12 +202,9 @@ export default function SearchResultItem({ item, typeItem, index, currentLoc, lo
}
useEffect(() => {
- if (ctx.selectedWptId?.id === itemId) {
- setIsHovered(true);
- } else {
- setIsHovered(false);
- }
- }, [ctx.selectedWptId?.id]);
+ const hoverIds = [ctx.selectedWptId?.id, ...(ctx.selectedWptId?.relatedResultIds ?? [])];
+ setIsHovered(ctx.selectedWptId?.show !== false && hoverIds.includes(itemId));
+ }, [ctx.selectedWptId?.id, ctx.selectedWptId?.relatedResultIds, ctx.selectedWptId?.show, itemId]);
function parseItem(item) {
const res = getPropsFromSearchResultItem(item.properties, t, null, ctx.listFiles, ctx.unitsSettings);
@@ -283,6 +292,7 @@ export default function SearchResultItem({ item, typeItem, index, currentLoc, lo
// click on category
const category = item.properties['web_keyName'];
if (category) {
+ moveToMatchedPoiTypeLocation();
return navigateToSearchResults({ type: category }, backToSearchResultsState);
} else {
// search by brand
@@ -294,6 +304,7 @@ export default function SearchResultItem({ item, typeItem, index, currentLoc, lo
brandType = `${brandType}:${brandRes.lang}`;
}
}
+ moveToMatchedPoiTypeLocation();
return navigateToSearchResults({ type: brandType }, backToSearchResultsState);
}
}
@@ -319,6 +330,12 @@ export default function SearchResultItem({ item, typeItem, index, currentLoc, lo
return ` · ${city}`;
}
+ const placeDetails = `${addInfo()}${addType()}${addCity()}`;
+
+ function hasTextBeforeMatchedName(index) {
+ return index > 0 || Boolean(placeDetails || distance > 0);
+ }
+
if (item.properties[CATEGORY_TYPE] === searchTypeMap.FAVORITE) {
const groupId = item.properties[FAVORITE_HIT_GROUP_ID];
const resolved = resolveFavoriteMarkerForSearch(ctx, groupId, name);
@@ -347,12 +364,12 @@ export default function SearchResultItem({ item, typeItem, index, currentLoc, lo
>
- {(info || type || matchedObjects.length > 1 || showPropertiesDumpIcon) && (
-
+ {(info ||
+ type ||
+ matchedNameObjects.length > 0 ||
+ matchedObjects.length > 1 ||
+ showPropertiesDumpIcon) && (
+
{distance > 0 && (
{' · '}
@@ -363,6 +380,25 @@ export default function SearchResultItem({ item, typeItem, index, currentLoc, lo
/>
)}
+ {matchedNameObjects.map(({ key, name, onClick, onKeyDown }, i) => (
+
+ {hasTextBeforeMatchedName(i) && (
+
+ {' · '}
+
+ )}
+
+ {name}
+
+
+ ))}
{matchedObjects.length > 1 && (
setShowMatched(false)} onClick={(e) => e.stopPropagation()}>
Matched objects ({matchedObjects.length})
- {matchedObjects.map((obj, i) => (
+ {matchedDialogObjects.map(({ key, obj, onClick }) => (
}
className={styles.matchedItem}
name={obj.name}
additionalInfo={`${obj.lat?.toFixed(5)}, ${obj.lon?.toFixed(5)}`}
- onClick={() => openMatchedObject(obj)}
+ onClick={onClick}
/>
))}
diff --git a/map/src/menu/search/search/SearchResults.jsx b/map/src/menu/search/search/SearchResults.jsx
index 9ccebc966..8fe53f1ef 100644
--- a/map/src/menu/search/search/SearchResults.jsx
+++ b/map/src/menu/search/search/SearchResults.jsx
@@ -385,7 +385,7 @@ export default function SearchResults() {
}
defaultSearchValue={
- ctx.searchQuery?.query ||
+ ctx.searchQuery?.query ??
(params?.type
? (() => {
const brandInfo = parseBrandType(params.type);
diff --git a/map/src/util/hooks/map/useSelectMarkerOnMap.js b/map/src/util/hooks/map/useSelectMarkerOnMap.js
index be949a4c3..cf2205f25 100644
--- a/map/src/util/hooks/map/useSelectMarkerOnMap.js
+++ b/map/src/util/hooks/map/useSelectMarkerOnMap.js
@@ -9,6 +9,7 @@ import {
import {
EXPLORE_PHOTO_ICON_SIZE,
applySelectedPin,
+ applySelectedPins,
applyHoverOutline,
applyDirectionPin,
resetSelectedPin,
@@ -67,13 +68,29 @@ function resolveLayers(getLayers, layersProp) {
}
function findLayerById(layers, id) {
- if (!layers?.length || !id) return null;
- return (
- layers.find((l) => {
- const opts = l?.options ?? {};
- return opts.idObj === id || opts[POI_ID] === id;
- }) ?? null
- );
+ if (!layers?.length || id == null) {
+ return null;
+ }
+
+ return layers.find((l) => layerHasIdentity(l, id)) ?? null;
+}
+
+function findLayersByRelatedResultId(layers, id) {
+ if (!layers?.length || id == null) {
+ return [];
+ }
+
+ return layers.filter((l) => layerHasIdentity(l, id) || layerHasRelatedResultId(l, id));
+}
+
+function layerHasIdentity(layer, id) {
+ const opts = layer?.options ?? {};
+
+ return opts.idObj === id || opts[POI_ID] === id;
+}
+
+function layerHasRelatedResultId(layer, id) {
+ return (layer?.options?.relatedResultIds ?? []).includes(id);
}
export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type, map, zoom, move }) {
@@ -86,12 +103,15 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
ctx.selectedWptId?.id != null
? ctx.selectedWptId.id
: null;
-
useEffect(() => {
- if (zoom === undefined || move === undefined) return;
+ if (zoom === undefined || move === undefined) {
+ return;
+ }
if (ctx.selectedWpt?.id != null) return;
- if (ctx.selectedWptId?.type !== type || ctx.selectedWptId?.show !== true) return;
+ if (ctx.selectedWptId?.type !== type || ctx.selectedWptId?.show !== true) {
+ return;
+ }
ctx.setSelectedWptId((prev) => (prev ? { ...prev, show: false } : prev));
}, [type, zoom, move]);
@@ -152,7 +172,12 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
// ========== HOVER PIN ==========
useEffect(() => {
- if (!map || selectedObjId) return;
+ if (!map || selectedObjId) {
+ return;
+ }
+ if (ctx.selectedWptId?.type != null && ctx.selectedWptId.type !== type) {
+ return;
+ }
if (!hoverId) {
if (isAddFavoritePreviewActive(ctx)) {
@@ -167,23 +192,17 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
return;
}
- const found = findLayerById(resolveLayers(getLayers, layersProp), hoverId);
+ const layers = resolveLayers(getLayers, layersProp);
+ const found = findLayerById(layers, hoverId);
+ const relatedLayers =
+ type === SEARCH_LAYER_ID && !ctx.selectedWptId?.hoverFromMap
+ ? findLayersByRelatedResultId(layers, hoverId)
+ : [];
// Hover originating on the map: show an outline ring around the point instead of
// replacing the marker with a full selected pin. List hover keeps the pin behavior below.
if (ctx.selectedWptId?.hoverFromMap) {
- // Secondary (simple dot) points across all layers get no hover outline.
- if (found?.options?.simple) {
- resetSelectedPin({ ctx, map });
- return;
- }
-
- const latlng = found?.getLatLng() ?? extractLatlng(ctx.selectedWptId, type);
- if (latlng) {
- applyHoverOutline({ ctx, map, latlng, ...resolveHoverOutlineStyle(found) });
- } else {
- resetSelectedPin({ ctx, map });
- }
+ applyMapHoverOutline(found);
return;
}
@@ -195,7 +214,11 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
}
if (found) {
- applyPinForLayer(found, false);
+ if (relatedLayers.length > 1) {
+ applyPinsForLayers(relatedLayers);
+ } else {
+ applyPinForLayer(found, false);
+ }
} else if (latlng) {
applyHoverPinFallback(latlng);
} else if (type === TRANSPORT_STOPS_LAYER_ID) {
@@ -203,6 +226,21 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
}
}, [hoverId, selectedObjId, type, getLayers, layersProp, ctx.addFavorite?.location, ctx.addFavorite?.editWpt]);
+ function applyMapHoverOutline(layer) {
+ // Secondary (simple dot) points across all layers get no hover outline.
+ if (layer?.options?.simple) {
+ resetSelectedPin({ ctx, map });
+ return;
+ }
+
+ const latlng = ctx.selectedWptId?.hoverLatlng ?? layer?.getLatLng() ?? extractLatlng(ctx.selectedWptId, type);
+ if (latlng) {
+ applyHoverOutline({ ctx, map, latlng, ...resolveHoverOutlineStyle(layer) });
+ } else {
+ resetSelectedPin({ ctx, map });
+ }
+ }
+
// Resolves the outline ring shape/color/size from the hovered layer (falls back to selectedWptId markerOptions).
function resolveHoverOutlineStyle(layer) {
const opts = layer?.options ?? {};
@@ -234,7 +272,8 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
// Skip if this layer is already the active pin
const currentRef = isSelection ? ctx.selectedCreatedLayerRef?.current : ctx.selectedUpdatedLayerRef?.current;
- if (currentRef === layer) return;
+ const currentRefs = Array.isArray(currentRef) ? currentRef : currentRef ? [currentRef] : [];
+ if (currentRefs.includes(layer)) return;
const photoUrl = layer.options?.photoUrl;
if (photoUrl) {
@@ -243,21 +282,37 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
}
const markerData = buildMarkerData(layer, isSelection, type);
- if (!markerData.iconHtml && layer.options?.simple) {
- const props = ctx.selectedWptId?.obj?.properties;
- markerData.iconHtml = iconHtmlFromIconName(
- props?.[FINAL_POI_ICON_NAME] ??
- getIconNameForPoiType({
- iconKeyName: props?.[ICON_KEY_NAME],
- typeOsmTag: props?.[TYPE_OSM_TAG],
- typeOsmValue: props?.[TYPE_OSM_VALUE],
- })
- );
- }
+ fillSimpleMarkerIcon(markerData, layer);
applySelectedPin({ ctx, map, layer, latlng, markerData, isSelection });
}
+ function applyPinsForLayers(layers) {
+ const items = layers
+ .filter((layer) => layer?.getLatLng?.())
+ .map((layer) => {
+ const markerData = buildMarkerData(layer, false, type);
+ fillSimpleMarkerIcon(markerData, layer);
+ return { layer, markerData };
+ });
+ applySelectedPins({ ctx, map, items });
+ }
+
+ function fillSimpleMarkerIcon(markerData, layer) {
+ if (markerData.iconHtml || !layer.options?.simple) {
+ return;
+ }
+ const props = layer.options ?? ctx.selectedWptId?.obj?.properties;
+ markerData.iconHtml = iconHtmlFromIconName(
+ props?.[FINAL_POI_ICON_NAME] ??
+ getIconNameForPoiType({
+ iconKeyName: props?.[ICON_KEY_NAME],
+ typeOsmTag: props?.[TYPE_OSM_TAG],
+ typeOsmValue: props?.[TYPE_OSM_VALUE],
+ })
+ );
+ }
+
function photoIconHtml(photoUrl) {
return `
`;
}
@@ -284,17 +339,7 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
if (layer) {
const markerData = buildMarkerData(layer, false, type);
- if (!markerData.iconHtml && layer.options?.simple) {
- const props = ctx.selectedWptId?.obj?.properties;
- markerData.iconHtml = iconHtmlFromIconName(
- props?.[FINAL_POI_ICON_NAME] ??
- getIconNameForPoiType({
- iconKeyName: props?.[ICON_KEY_NAME],
- typeOsmTag: props?.[TYPE_OSM_TAG],
- typeOsmValue: props?.[TYPE_OSM_VALUE],
- })
- );
- }
+ fillSimpleMarkerIcon(markerData, layer);
return markerData;
}