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; }