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