diff --git a/map/src/assets/map/hover_point_circle.svg b/map/src/assets/map/hover_point_circle.svg new file mode 100644 index 0000000000..c95af64e99 --- /dev/null +++ b/map/src/assets/map/hover_point_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/assets/map/hover_point_octagon.svg b/map/src/assets/map/hover_point_octagon.svg new file mode 100644 index 0000000000..9523f093e1 --- /dev/null +++ b/map/src/assets/map/hover_point_octagon.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/assets/map/hover_point_square.svg b/map/src/assets/map/hover_point_square.svg new file mode 100644 index 0000000000..cf0dc996a2 --- /dev/null +++ b/map/src/assets/map/hover_point_square.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/assets/map/ic_pin_circle_outside_color.svg b/map/src/assets/map/ic_pin_circle_outside_color.svg new file mode 100644 index 0000000000..51c7f15717 --- /dev/null +++ b/map/src/assets/map/ic_pin_circle_outside_color.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/assets/map/ic_pin_circle_outside_light.svg b/map/src/assets/map/ic_pin_circle_outside_light.svg new file mode 100644 index 0000000000..d2b4843a14 --- /dev/null +++ b/map/src/assets/map/ic_pin_circle_outside_light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/map/src/assets/map/ic_pin_circle_outside_stroke.svg b/map/src/assets/map/ic_pin_circle_outside_stroke.svg new file mode 100644 index 0000000000..ae0289b64b --- /dev/null +++ b/map/src/assets/map/ic_pin_circle_outside_stroke.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/map/layers/MapStateLayer.js b/map/src/map/layers/MapStateLayer.js index d4bf61e245..01fd281a65 100644 --- a/map/src/map/layers/MapStateLayer.js +++ b/map/src/map/layers/MapStateLayer.js @@ -17,6 +17,30 @@ export function getVisibleBboxInfo(ctx, map) { return params ? calcVisibleBbox(params.topLeft, params.bottomRight) : null; } +export function visibleMapRectPx(ctx, map) { + const params = calcVisibleBboxParamsPx(map, ctx); + if (!params) { + return null; + } + const tl = map.latLngToContainerPoint(params.topLeft); + const br = map.latLngToContainerPoint(params.bottomRight); + + return { left: tl.x, top: tl.y, right: br.x, bottom: br.y, cx: params.centerPx.x, cy: params.centerPx.y }; +} + +export function isOutsideVisibleMap({ ctx, map, latlng }) { + if (!ctx || !map || !latlng) { + return false; + } + const rect = visibleMapRectPx(ctx, map); + if (!rect) { + return false; + } + const p = map.latLngToContainerPoint(L.latLng(latlng)); + + return p.x < rect.left || p.x > rect.right || p.y < rect.top || p.y > rect.bottom; +} + export function getMapCenter(mtx, hash) { return mtx.visibleBboxInfo?.center ?? getCenterMapLocByHash(hash); } diff --git a/map/src/map/layers/NavigationLayer.js b/map/src/map/layers/NavigationLayer.js index e12b1b0d5c..4b94d20b45 100644 --- a/map/src/map/layers/NavigationLayer.js +++ b/map/src/map/layers/NavigationLayer.js @@ -20,6 +20,7 @@ import { navigationObject } from '../../store/navigationObject/navigationObject' import { pickNextRoutePoint } from '../../manager/NavigationManager'; const DRAG_DEBOUNCE_MS = 10; +const TURN_DOT_Z_INDEX_OFFSET = 1100; function setMarkerIconHtml(marker, html) { const el = marker?.getElement(); @@ -403,13 +404,14 @@ const NavigationLayer = ({ geocodingData, region }) => { L.marker(latlng, { icon: makeDotIcon(opts.fillColor, opts.fillOpacity), interactive: true, + zIndexOffset: TURN_DOT_Z_INDEX_OFFSET, }) ); }; const pointToLayerGeoData = (feature, latlng) => { let opts = { ...geojsonMarkerOptions }; - if (feature.properties && feature.properties.index) { + if (feature.properties?.index) { opts.fillOpacity = Math.min(1 / Math.log(feature.properties.index + 2), 1); let clrs = ['#6DD6DA', '#95D9DA', '#A2ABB5', '#AE8CA3', '#817F82']; let indx = [2, 5, 7, 10, 20]; diff --git a/map/src/map/markers/SelectedPinMarker.js b/map/src/map/markers/SelectedPinMarker.js index e0715481e1..cf5e55d7ef 100644 --- a/map/src/map/markers/SelectedPinMarker.js +++ b/map/src/map/markers/SelectedPinMarker.js @@ -11,6 +11,12 @@ import { ReactComponent as SquareStroke } from '../../assets/map/map_pin_square_ import { ReactComponent as HexagonColor } from '../../assets/map/map_pin_hexagon_color.svg'; import { ReactComponent as HexagonLight } from '../../assets/map/map_pin_hexagon_light.svg'; import { ReactComponent as HexagonStroke } from '../../assets/map/map_pin_hexagon_stroke.svg'; +import { ReactComponent as HoverCircle } from '../../assets/map/hover_point_circle.svg'; +import { ReactComponent as HoverSquare } from '../../assets/map/hover_point_square.svg'; +import { ReactComponent as HoverOctagon } from '../../assets/map/hover_point_octagon.svg'; +import { ReactComponent as DirectionColor } from '../../assets/map/ic_pin_circle_outside_color.svg'; +import { ReactComponent as DirectionLight } from '../../assets/map/ic_pin_circle_outside_light.svg'; +import { ReactComponent as DirectionStroke } from '../../assets/map/ic_pin_circle_outside_stroke.svg'; import { SELECTED_ICON_SIZE, SELECTED_PIN_COLOR, SELECTED_PIN_SIZE } from '../util/MarkerSelectionService'; import { DEFAULT_POI_SHAPE } from '../../manager/PoiManager'; @@ -18,6 +24,18 @@ import { DEFAULT_POI_SHAPE } from '../../manager/PoiManager'; const PIN_VIEWBOX_SIZE = 70; const PIN_TIP_CENTER_Y = 67; +export const HOVER_OUTLINE_SIZE = 34; +const DIRECTION_PIN_SIZE = 60; +const DIRECTION_PIN_ICON_SIZE = 26; +const DIRECTION_PIN_TAIL_TIP_RATIO = 74 / 82; + +const HOVER_OUTLINE_SHAPES = { + circle: HoverCircle, + square: HoverSquare, + octagon: HoverOctagon, + hexagon: HoverOctagon, +}; + const SHAPES = { circle: { color: CircleColor, @@ -74,6 +92,65 @@ function prepareInnerIcon(html, iconSize) { return changeIconSizeWpt(withoutShadow, iconSize, iconSize); } +export function createHoverOutlineIcon({ shape, color, size } = {}) { + const px = Math.max(Number(size) || 0, HOVER_OUTLINE_SIZE); + const shapeKey = shape === 'octagon' || shape === 'hexagon' ? 'octagon' : shape; + const Component = HOVER_OUTLINE_SHAPES[shapeKey] ?? HOVER_OUTLINE_SHAPES.circle; + const svg = resizeSvg(renderToStaticMarkup(React.createElement(Component)), px); + const html = `
${svg}
`; + + return L.divIcon({ + html, + className: '', + iconSize: [px, px], + iconAnchor: [px / 2, px / 2], + }); +} + +export function createDirectionPinIcon({ + color, + iconHtml, + invertIcon, + angle = 0, + size = DIRECTION_PIN_SIZE, + iconSize = DIRECTION_PIN_ICON_SIZE, +} = {}) { + const colorSvg = renderToStaticMarkup(React.createElement(DirectionColor)); + const lightSvg = renderToStaticMarkup(React.createElement(DirectionLight)); + const strokeSvg = renderToStaticMarkup(React.createElement(DirectionStroke)); + + const coloredLayer = resizeSvg(changeSvgColor(colorSvg, color), size); + const strokeLayer = resizeSvg(strokeSvg, size); + const lightLayer = resizeSvg(lightSvg, size); + + const markerIconHtml = prepareInnerIcon(iconHtml, iconSize); + const iconWrapperSize = Math.min(iconSize, size); + const tipX = size / 2; + const tipY = size * DIRECTION_PIN_TAIL_TIP_RATIO; + + const html = ` +
+
${coloredLayer}
+
${strokeLayer}
+
${lightLayer}
+ ${ + markerIconHtml + ? `
+ ${markerIconHtml} +
` + : '' + } +
+ `; + + return L.divIcon({ + html, + className: '', + iconSize: [size, size], + iconAnchor: [tipX, tipY], + }); +} + export function createLayeredPinIcon(options = {}) { const merged = { size: SELECTED_PIN_SIZE, diff --git a/map/src/map/util/Clusterizer.js b/map/src/map/util/Clusterizer.js index 3b0ec1c506..e469c38971 100644 --- a/map/src/map/util/Clusterizer.js +++ b/map/src/map/util/Clusterizer.js @@ -385,7 +385,7 @@ export function addMarkerTooltip({ marker.on('mouseover', () => { removeTooltip(map, tooltipRef); - setSelectedId?.({ id: marker.options.idObj, show: true, type }); + setSelectedId?.({ id: marker.options.idObj, show: true, type, hoverFromMap: true }); if (text) { const offset = mainStyle ? [5, iconSize * 0.8] : [0, iconSize * 0.8]; tooltipRef.current = createTooltip(Utils.truncateText(text, TOOLTIP_MAX_LENGTH), latlng, { offset }); diff --git a/map/src/map/util/MarkerSelectionService.js b/map/src/map/util/MarkerSelectionService.js index 242ca60da9..baa29c0ef4 100644 --- a/map/src/map/util/MarkerSelectionService.js +++ b/map/src/map/util/MarkerSelectionService.js @@ -1,8 +1,10 @@ import { DEFAULT_WPT_COLOR } from '../markers/MarkerOptions'; -import { createLayeredPinIcon } from '../markers/SelectedPinMarker'; +import { createLayeredPinIcon, createHoverOutlineIcon, createDirectionPinIcon } from '../markers/SelectedPinMarker'; import L from 'leaflet'; import { hexToRgba } from '../../util/ColorUtil'; import { updateMarkerZIndex } from '../layers/ExploreLayer'; +import { DEFAULT_POI_COLOR, DEFAULT_POI_SHAPE } from '../../manager/PoiManager'; +import { visibleMapRectPx } from '../layers/MapStateLayer'; export const SELECTED_PIN_SIZE = 70; export const SELECTED_ICON_SIZE = 36; @@ -14,6 +16,8 @@ const SELECTED_MARKER_HIDE_MAX_ZOOM = 16; const SELECTED_MARKER_HIDE_RADIUS_COEFF = 300 / 16; const SELECTED_MARKER_HIDE_MIN_RADIUS_M = 50; +const DIRECTION_PIN_TIP_MARGIN = 6; + export const toShape = (s) => (s === 'octagon' || s === 'hexagon' ? 'hexagon' : s); const toColor = (c) => { @@ -137,6 +141,86 @@ export function restoreOriginalIcon(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) { + return null; + } + + resetSelectedPin({ ctx, map }); + + const ll = latlng ?? layer?.getLatLng(); + if (!ll) { + return null; + } + const latlngObj = ll.lat !== undefined && ll.lng !== undefined ? L.latLng(ll.lat, ll.lng) : ll; + + const outline = L.marker(latlngObj, { + icon: createHoverOutlineIcon({ + shape: shape ?? DEFAULT_POI_SHAPE, + color: toColor(color ?? DEFAULT_POI_COLOR), + size, + }), + interactive: false, + zIndexOffset: SELECTED_MARKER_Z_INDEX, + }); + outline.addTo(map); + ctx.selectedCreatedLayerRef.current = outline; + + return outline; +} + +function rayRectEdge(cx, cy, ux, uy, left, top, right, bottom) { + let t = Infinity; + if (ux > 0) t = Math.min(t, (right - cx) / ux); + else if (ux < 0) t = Math.min(t, (left - cx) / ux); + if (uy > 0) t = Math.min(t, (bottom - cy) / uy); + else if (uy < 0) t = Math.min(t, (top - cy) / uy); + if (!Number.isFinite(t)) { + t = 0; + } + + return L.point(cx + ux * t, cy + uy * t); +} + +export function applyDirectionPin({ ctx, map, latlng, markerData }) { + if (!ctx || !map || !latlng || !markerData) { + return null; + } + + resetSelectedPin({ ctx, map }); + + const rect = visibleMapRectPx(ctx, map); + if (!rect) { + return null; + } + const target = map.latLngToContainerPoint(L.latLng(latlng)); + let ux = target.x - rect.cx; + let uy = target.y - rect.cy; + if (ux === 0 && uy === 0) { + uy = 1; + } + + const m = DIRECTION_PIN_TIP_MARGIN; + const tip = rayRectEdge(rect.cx, rect.cy, ux, uy, rect.left + m, rect.top + m, rect.right - m, rect.bottom - m); + const angle = (Math.atan2(-ux, uy) * 180) / Math.PI; + + const pin = L.marker(map.containerPointToLatLng(tip), { + icon: createDirectionPinIcon({ + color: toColor(markerData.color ?? DEFAULT_POI_COLOR), + iconHtml: markerData.iconHtml, + invertIcon: markerData.invertIcon, + angle, + }), + interactive: false, + zIndexOffset: SELECTED_MARKER_Z_INDEX, + }); + pin.addTo(map); + ctx.selectedCreatedLayerRef.current = pin; + + return pin; +} + // 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 }) { diff --git a/map/src/util/hooks/map/useSelectMarkerOnMap.js b/map/src/util/hooks/map/useSelectMarkerOnMap.js index a84db56a6e..a80ba93388 100644 --- a/map/src/util/hooks/map/useSelectMarkerOnMap.js +++ b/map/src/util/hooks/map/useSelectMarkerOnMap.js @@ -6,14 +6,23 @@ import { TYPE_OSM_TAG, TYPE_OSM_VALUE, } from '../../../infoblock/components/wpt/WptTagsProvider'; -import { EXPLORE_PHOTO_ICON_SIZE, applySelectedPin, resetSelectedPin } from '../../../map/util/MarkerSelectionService'; +import { + EXPLORE_PHOTO_ICON_SIZE, + applySelectedPin, + applyHoverOutline, + applyDirectionPin, + resetSelectedPin, +} from '../../../map/util/MarkerSelectionService'; +import { isOutsideVisibleMap } from '../../../map/layers/MapStateLayer'; import { DEFAULT_POI_COLOR, DEFAULT_POI_SHAPE, getIconNameForPoiType } from '../../../manager/PoiManager'; import { getIconUrlByName } from '../../../map/markers/MarkerOptions'; import { iconPathMap } from '../../../map/util/MapManager'; import { FAVORITE_FILE_TYPE } from '../../../manager/FavoritesManager'; import { TRANSPORT_STOPS_LAYER_ID } from '../../../map/layers/TransportStopsLayer'; +import { SEARCH_LAYER_ID } from '../../../manager/GlobalManager'; const EXPLORE_MAIN_MARKER_PIN_BACKGROUND = '#ffffff'; +const HOVER_OUTLINE_GAP = 6; function extractLatlng(selectedWptId, type) { const obj = selectedWptId?.obj; @@ -28,6 +37,14 @@ function extractLatlng(selectedWptId, type) { return null; } +function escapeHtmlAttr(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + function iconHtmlFromIconName(finalIconName) { if (!finalIconName) return null; const url = @@ -133,19 +150,65 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type, } const found = findLayerById(resolveLayers(getLayers, layersProp), hoverId); - if (found) { - applyPinForLayer(found, false); - } else { - // Marker not on the map (e.g. hidden by clustering) — create a temporary hover pin. - const latlng = extractLatlng(ctx.selectedWptId, type); + + // 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) { - applyHoverPinFallback(latlng); - } else if (type === TRANSPORT_STOPS_LAYER_ID) { + applyHoverOutline({ ctx, map, latlng, ...resolveHoverOutlineStyle(found) }); + } else { resetSelectedPin({ ctx, map }); } + return; + } + + const latlng = found?.getLatLng() ?? extractLatlng(ctx.selectedWptId, type); + + if (type === SEARCH_LAYER_ID && latlng && isOutsideVisibleMap({ ctx, map, latlng })) { + applyDirectionPin({ ctx, map, latlng, markerData: resolveHoverMarkerData(found) }); + return; + } + + if (found) { + applyPinForLayer(found, false); + } else if (latlng) { + applyHoverPinFallback(latlng); + } else if (type === TRANSPORT_STOPS_LAYER_ID) { + resetSelectedPin({ ctx, map }); } }, [hoverId, selectedObjId, type, getLayers, layersProp, ctx.addFavorite?.location, ctx.addFavorite?.editWpt]); + // Resolves the outline ring shape/color/size from the hovered layer (falls back to selectedWptId markerOptions). + function resolveHoverOutlineStyle(layer) { + const opts = layer?.options ?? {}; + const markerOpts = ctx.selectedWptId?.markerOptions ?? {}; + const isPhoto = !!opts.photoUrl; + const isSimpleDot = !!opts.simple; + const color = (isSimpleDot ? opts.fillColor : opts.color) ?? markerOpts.color ?? DEFAULT_POI_COLOR; + // Photo markers (e.g. large Explore images) are round — wrap them with a circle regardless of poi shape. + const shape = isPhoto ? 'circle' : (opts.background ?? markerOpts.background ?? DEFAULT_POI_SHAPE); + + return { shape, color, size: measureMarkerSize(layer) }; + } + + // Diameter to wrap the marker with: its rendered size plus a small gap (clamped to a minimum inside the icon builder). + function measureMarkerSize(layer) { + const el = layer?.getElement?.(); + const rendered = el ? Math.max(el.offsetWidth, el.offsetHeight) : 0; + if (rendered > 0) { + return rendered + HOVER_OUTLINE_GAP; + } + + return 0; + } + // Builds markerData from layer options function applyPinForLayer(layer, isSelection) { const latlng = layer.getLatLng(); @@ -177,10 +240,14 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type, applySelectedPin({ ctx, map, layer, latlng, markerData, isSelection }); } + function photoIconHtml(photoUrl) { + return ``; + } + function applyPhotoPin(layer, latlng, photoUrl, isSelection) { const markerData = { color: EXPLORE_MAIN_MARKER_PIN_BACKGROUND, - iconHtml: ``, + iconHtml: photoIconHtml(photoUrl), }; applySelectedPin({ ctx, map, layer, latlng, markerData, isSelection }); @@ -191,11 +258,26 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type, } } - function applyHoverPinFallback(latlng) { - const photoUrl = ctx.selectedWptId?.photoUrl; + function resolveHoverMarkerData(layer) { + const photoUrl = layer?.options?.photoUrl ?? ctx.selectedWptId?.photoUrl; if (photoUrl) { - applyPhotoPin(null, latlng, photoUrl, false); - return; + return { color: EXPLORE_MAIN_MARKER_PIN_BACKGROUND, iconHtml: photoIconHtml(photoUrl) }; + } + + 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], + }) + ); + } + return markerData; } const markerOpts = ctx.selectedWptId?.markerOptions ?? {}; @@ -213,17 +295,27 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type, // Only apply white filter when icon came from URL (iconHtmlFromIconName), not when caller passed custom iconHtml (e.g. favorites) const invertIcon = markerOpts.invertIcon ?? markerOpts.iconHtml == null; + return { + color: markerOpts.color ?? DEFAULT_POI_COLOR, + background: markerOpts.background ?? DEFAULT_POI_SHAPE, + iconHtml, + invertIcon, + }; + } + + function applyHoverPinFallback(latlng) { + const photoUrl = ctx.selectedWptId?.photoUrl; + if (photoUrl) { + applyPhotoPin(null, latlng, photoUrl, false); + return; + } + applySelectedPin({ ctx, map, layer: null, latlng, - markerData: { - color: markerOpts.color ?? DEFAULT_POI_COLOR, - background: markerOpts.background ?? DEFAULT_POI_SHAPE, - iconHtml, - invertIcon, - }, + markerData: resolveHoverMarkerData(null), isSelection: false, }); }