From 5ec1052907d2c2fa38eda9aea9dec0299d7e1bf6 Mon Sep 17 00:00:00 2001 From: Michael Sverdlin Date: Sun, 5 Jul 2026 10:02:53 +0300 Subject: [PATCH] Add labels and markers to the Street View panorama (#1081) Show the same dot markers and fruit-name labels in the Street View panorama as on the map, by extracting the map's marker/label rendering into a shared module (locationMarkerHelpers.js) and reusing it. The OverlayView labels attach to a StreetViewPanorama as well as a Map, so both views render identically. - All nearby locations get the map's dots (blue by default, orange when saved) plus their fruit-name label; labels use the satellite/hybrid style (white text, dark outline) since the panorama is imagery. - The selected/viewed location additionally gets the orange "here" pin above its dot, using the same boxicons Map glyph as the map's MapPin. - The map's own markers are hidden while Street View is open so they don't double up with the panorama's. - Guard against async re-entrancy so a superseded panorama can't leave orphaned overlays on the shared panorama. Closes #1081 Co-Authored-By: Claude Opus 4.8 --- public/selected_location_pin.svg | 3 + src/components/map/LocationMarkers.js | 228 ++------------------ src/components/map/PanoramaHandler.js | 163 ++++++++++++-- src/components/map/locationMarkerHelpers.js | 171 +++++++++++++++ src/utils/getDisplayLabel.js | 52 +++++ 5 files changed, 384 insertions(+), 233 deletions(-) create mode 100644 public/selected_location_pin.svg create mode 100644 src/components/map/locationMarkerHelpers.js create mode 100644 src/utils/getDisplayLabel.js diff --git a/public/selected_location_pin.svg b/public/selected_location_pin.svg new file mode 100644 index 000000000..b108db306 --- /dev/null +++ b/public/selected_location_pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/map/LocationMarkers.js b/src/components/map/LocationMarkers.js index e7442793d..3dc2d8f6c 100644 --- a/src/components/map/LocationMarkers.js +++ b/src/components/map/LocationMarkers.js @@ -1,214 +1,13 @@ import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' -import { MapType } from '../../constants/settings' -import { theme } from '../ui/GlobalStyle' - -const Z_INDEX = { - SAVED: 2, - DEFAULT: 1, -} - -const getDisplayLabel = (typesAccess, id) => { - const type = typesAccess.getType(id) - if (!type) { - return null - } - - if (type.cultivar) { - const parentType = typesAccess.getParentType(id) - if ( - parentType && - parentType.commonName && - parentType.scientificName && - (!type.commonName || - type.commonName.toLowerCase() === parentType.commonName.toLowerCase()) - ) { - return { - text: `${parentType.commonName} '${type.cultivar}'`, - isScientific: false, - typeId: id, - } - } - } - - if (type.commonName) { - return { - text: type.commonName, - isScientific: false, - typeId: id, - } - } - - if (type.scientificName) { - return { - text: type.scientificName, - isScientific: true, - typeId: id, - } - } - - return null -} - -const escapeHtml = (text) => { - const div = document.createElement('div') - div.textContent = text - return div.innerHTML -} - -const formatLabelHtml = (labelData, selectedTypes) => - labelData - .map((item) => { - const escapedText = escapeHtml(item.text) - const content = item.isScientific ? `${escapedText}` : escapedText - const isSelected = selectedTypes.includes(item.typeId) - const opacity = isSelected ? '1.0' : '0.5' - return `${content}` - }) - .join('
') - -const createTextShadow = (color, size = 1, times = 1) => { - const shadows = [] - for (let i = 0; i < times; i++) { - shadows.push(`0px 0px ${size}px ${color}`) - } - return shadows.join(', ') -} - -const getLabelStyleConfig = (mapType) => { - const configs = { - [MapType.Hybrid]: { - fontWeight: 500, - color: theme.background, - textShadow: createTextShadow(theme.headerText, 2, 5), - backgroundColor: 'unset', - }, - } - - return ( - configs[mapType] || { - fontWeight: 500, - color: theme.secondaryText, - textShadow: createTextShadow(theme.background, 2, 10), - backgroundColor: 'unset', - } - ) -} - -const setLabelTextStyle = (div, mapType) => { - const config = getLabelStyleConfig(mapType) - Object.keys(config).forEach((key) => { - div.style[key] = config[key] - }) -} - -const createBaseLabelDiv = (isSaved) => { - const div = document.createElement('div') - div.style.position = 'absolute' - div.style.padding = '4px 8px' - div.style.fontSize = '12px' - div.style.pointerEvents = 'none' - div.style.marginTop = '5px' - div.style.textAlign = 'center' - div.style.display = 'block' - div.style.zIndex = isSaved ? Z_INDEX.SAVED : Z_INDEX.DEFAULT - return div -} - -const createLabel = ( - google, - googleMap, - location, - labelHtml, - isHovered, - mapType, - isSaved, -) => { - const label = new google.OverlayView() - label.position = new google.LatLng(location.lat, location.lng) - label.labelHtml = labelHtml - label.locationId = location.id - label.overlayLayerPane = null - label.overlayMouseTargetPane = null - label.isHovered = isHovered - label.mapType = mapType - label.isSaved = isSaved - - label.onAdd = function () { - const div = createBaseLabelDiv(this.isSaved) - setLabelTextStyle(div, this.mapType) - div.innerHTML = this.labelHtml - - this.div = div - const panes = this.getPanes() - this.overlayLayerPane = panes.overlayLayer - this.overlayMouseTargetPane = panes.overlayMouseTarget - const targetPane = this.isHovered - ? this.overlayMouseTargetPane - : this.overlayLayerPane - targetPane.appendChild(div) - } - - label.draw = function () { - const projection = this.getProjection() - const position = projection.fromLatLngToDivPixel(this.position) - - const div = this.div - div.style.left = `${position.x}px` - div.style.top = `${position.y}px` - div.style.transform = 'translate(-50%, 0)' - } - - label.onRemove = function () { - if (this.div) { - this.div.parentNode.removeChild(this.div) - this.div = null - } - } - - label.moveToPane = function (isHovered) { - if (!this.div || !this.overlayLayerPane || !this.overlayMouseTargetPane) { - return - } - const targetPane = isHovered - ? this.overlayMouseTargetPane - : this.overlayLayerPane - if (this.div.parentNode !== targetPane) { - targetPane.appendChild(this.div) - } - } - - label.updateStyle = function (mapType) { - if (!this.div) { - return - } - this.mapType = mapType - setLabelTextStyle(this.div, mapType) - } - - label.updatePosition = function (google, lat, lng) { - this.position = new google.LatLng(lat, lng) - this.draw() - } - - label.updateHtml = function (newHtml) { - if (!this.div || this.labelHtml === newHtml) { - return - } - this.labelHtml = newHtml - this.div.innerHTML = newHtml - } - - label.setMap(googleMap) - return label -} - -const getMarkerIcon = (google, isSaved) => ({ - url: isSaved ? '/saved_location_dot.svg' : '/location_blue_dot.svg', - anchor: new google.Point(8, 8), - scaledSize: new google.Size(16, 16), -}) +import { getDisplayLabel } from '../../utils/getDisplayLabel' +import { + createLabel, + formatLabelHtml, + getMarkerIcon, + Z_INDEX, +} from './locationMarkerHelpers' const LocationMarkers = ({ locations, @@ -222,6 +21,7 @@ const LocationMarkers = ({ const typesAccess = useSelector((state) => state.type.typesAccess) const { types: selectedTypes } = useSelector((state) => state.filter) const { mapType } = useSelector((state) => state.settings) + const streetViewOpen = useSelector((state) => state.location.streetViewOpen) useEffect(() => { if (!googleMap || !getGoogleMaps) { @@ -231,7 +31,12 @@ const LocationMarkers = ({ const google = getGoogleMaps() const currentMarkers = markersRef.current - const newLocationIds = new Set(locations.map((loc) => loc.id)) + // While Street View is open, Google projects the map markers into the + // panorama. Hide them all there; PanoramaHandler draws the dots and + // fruit-name labels (and the selected "here" pin) for the panorama instead. + const visibleLocations = streetViewOpen ? [] : locations + + const newLocationIds = new Set(visibleLocations.map((loc) => loc.id)) const existingLocationIds = new Set(currentMarkers.keys()) existingLocationIds.forEach((locationId) => { @@ -248,7 +53,7 @@ const LocationMarkers = ({ } }) - locations.forEach((location) => { + visibleLocations.forEach((location) => { const isSaved = Boolean(location.in_list) if (!existingLocationIds.has(location.id)) { @@ -259,7 +64,7 @@ const LocationMarkers = ({ const marker = new google.Marker({ position: { lat: location.lat, lng: location.lng }, map: googleMap, - optimized: locations.length > 100, + optimized: visibleLocations.length > 100, icon: getMarkerIcon(google, isSaved), zIndex: isSaved ? Z_INDEX.SAVED : Z_INDEX.DEFAULT, }) @@ -386,6 +191,7 @@ const LocationMarkers = ({ showLabels, hoveredLocationId, mapType, + streetViewOpen, ]) useEffect( diff --git a/src/components/map/PanoramaHandler.js b/src/components/map/PanoramaHandler.js index a0805cf9a..cb58403e4 100644 --- a/src/components/map/PanoramaHandler.js +++ b/src/components/map/PanoramaHandler.js @@ -3,16 +3,106 @@ import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { toast } from 'react-toastify' +import { MapType } from '../../constants/settings' import { addLocationWithoutPanorama } from '../../redux/miscSlice' +import { getDisplayLabel } from '../../utils/getDisplayLabel' import { useAppHistory } from '../../utils/useAppHistory' +import { + createLabel, + formatLabelHtml, + getMarkerIcon, + Z_INDEX, +} from './locationMarkerHelpers' + +// The orange "here" pin drawn above the selected location's dot. Uses the same +// boxicons Map glyph (in theme.orange) as the map's MapPin in Pins.js, anchored +// at the tip so it floats above the dot. +const getSelectedPinIcon = (googleMaps) => ({ + url: '/selected_location_pin.svg', + scaledSize: new googleMaps.Size(48, 48), + anchor: new googleMaps.Point(24, 44), +}) + +// Dedupe the nearby locations by id (the viewed location may also appear in +// the map's location list). +const dedupeLocations = (mapLocations, centerLocation) => { + const byId = new Map() + const add = (loc) => { + if (loc && loc.id != null && loc.lat != null && loc.lng != null) { + byId.set(loc.id, loc) + } + } + ;(mapLocations || []).forEach(add) + add(centerLocation) + return [...byId.values()] +} -class PanoramaWithMarker { - constructor(googleMap, googleMaps, location) { +// Draws the same dot markers and labels as the map (LocationMarkers) onto the +// Street View panorama, so both views look identical. The selected (viewed) +// location additionally gets the orange "here" pin above its dot, mirroring +// the map's SelectedLocation pin. +class PanoramaWithMarkers { + constructor(googleMap, googleMaps, options) { this.googleMap = googleMap this.googleMaps = googleMaps + this.centerLocation = options.centerLocation + this.locations = options.locations + this.selectedLocationId = options.selectedLocationId + this.typesAccess = options.typesAccess + this.selectedTypes = options.selectedTypes this.panorama = null - this.location = location - this.marker = null + this.markers = [] + this.labels = [] + this.cancelled = false + } + + createOverlays() { + const google = this.googleMaps + this.locations.forEach((location) => { + const isSaved = Boolean(location.in_list) + const isSelected = location.id === this.selectedLocationId + const position = { lat: location.lat, lng: location.lng } + + // Same dot as the map (blue by default, orange when saved). + const marker = new google.Marker({ + position, + icon: getMarkerIcon(google, isSaved), + zIndex: isSaved ? Z_INDEX.SAVED : Z_INDEX.DEFAULT, + }) + marker.setMap(this.panorama) + this.markers.push(marker) + + // The selected location also gets the orange "here" pin above its dot. + if (isSelected) { + const pin = new google.Marker({ + position, + icon: getSelectedPinIcon(google), + zIndex: Z_INDEX.SAVED + 1, + }) + pin.setMap(this.panorama) + this.markers.push(pin) + } + + const labelData = (location.type_ids || []) + .map((id) => getDisplayLabel(this.typesAccess, id)) + .filter(Boolean) + if (labelData.length > 0) { + const labelHtml = formatLabelHtml(labelData, this.selectedTypes) + // Always use the satellite/hybrid label style (white text, dark + // outline) since the panorama is imagery — more readable than the + // road-map style regardless of the map's current type. + const label = createLabel( + google, + this.panorama, + location, + labelHtml, + false, + MapType.Hybrid, + isSaved, + ) + this.labels.push(label) + } + }) } async initPanorama() { @@ -21,29 +111,33 @@ class PanoramaWithMarker { disableDefaultUI: true, enableCloseButton: false, }) - this.marker = new this.googleMaps.Marker({ - position: this.location, - }) const panoClient = new this.googleMaps.StreetViewService() try { const panoData = await panoClient.getPanorama({ - location: this.location, + location: this.centerLocation, radius: 50, }) + // disconnect() may have run while we awaited getPanorama (a newer + // panorama superseded us). Bail before touching the shared panorama or + // creating overlays that nothing would clean up. + if (this.cancelled) { + return {} + } + const panoLocation = panoData.data.location.latLng const heading = this.googleMaps.geometry.spherical.computeHeading( panoLocation, - this.location, + this.centerLocation, ) this.panorama.setPosition(panoLocation) this.panorama.setPov({ heading, pitch: 0 }) this.panorama.setVisible(true) - this.marker.setMap(this.panorama) + this.createOverlays() - // bug: the marker does not immediately appear + // bug: the markers do not immediately appear // until the user interacts with the screen // programatically jiggle the screen slightly as a workaround setTimeout(() => { @@ -64,34 +158,50 @@ class PanoramaWithMarker { } disconnect() { + this.cancelled = true if (this.panorama) { this.panorama.setVisible(false) } - if (this.marker) { - this.marker.setMap(null) - } + this.markers.forEach((marker) => marker.setMap(null)) + this.labels.forEach((label) => label.setMap(null)) + this.markers = [] + this.labels = [] } } const PanoramaHandler = () => { const { t } = useTranslation() - const { googleMap, getGoogleMaps } = useSelector((state) => state.map) + const { + googleMap, + getGoogleMaps, + locations: mapLocations, + } = useSelector((state) => state.map) const googleMaps = getGoogleMaps ? getGoogleMaps() : null const { location, streetViewOpen: showStreetView } = useSelector( (state) => state.location, ) + const typesAccess = useSelector((state) => state.type.typesAccess) + const { types: selectedTypes } = useSelector((state) => state.filter) const history = useAppHistory() const dispatch = useDispatch() const panoramaWithMarkerRef = useRef(null) const connect = async () => { if (showStreetView && googleMap && googleMaps && location) { - panoramaWithMarkerRef.current = new PanoramaWithMarker( - googleMap, - googleMaps, - location, - ) - const { error } = await panoramaWithMarkerRef.current.initPanorama() + const instance = new PanoramaWithMarkers(googleMap, googleMaps, { + centerLocation: location, + locations: dedupeLocations(mapLocations, location), + selectedLocationId: location.id, + typesAccess, + selectedTypes, + }) + panoramaWithMarkerRef.current = instance + const { error } = await instance.initPanorama() + // A newer connect()/disconnect() superseded us while awaiting; bail so we + // don't toast or navigate for a panorama that is no longer current. + if (panoramaWithMarkerRef.current !== instance) { + return + } if (error) { toast.error( t('error_message.api.street_view_unavailable', { id: location.id }), @@ -123,7 +233,16 @@ const PanoramaHandler = () => { return () => { disconnect() } - }, [showStreetView, googleMap, googleMaps, location]) //eslint-disable-line + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + showStreetView, + googleMap, + googleMaps, + location, + typesAccess, + mapLocations, + selectedTypes, + ]) return null } diff --git a/src/components/map/locationMarkerHelpers.js b/src/components/map/locationMarkerHelpers.js new file mode 100644 index 000000000..1b3dd9ca2 --- /dev/null +++ b/src/components/map/locationMarkerHelpers.js @@ -0,0 +1,171 @@ +import { MapType } from '../../constants/settings' +import { theme } from '../ui/GlobalStyle' + +// Shared marker + label rendering used by both the map (LocationMarkers) and +// the Street View panorama (PanoramaHandler), so both look identical. + +export const Z_INDEX = { + SAVED: 2, + DEFAULT: 1, +} + +const escapeHtml = (text) => { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +export const formatLabelHtml = (labelData, selectedTypes) => + labelData + .map((item) => { + const escapedText = escapeHtml(item.text) + const content = item.isScientific ? `${escapedText}` : escapedText + const isSelected = selectedTypes.includes(item.typeId) + const opacity = isSelected ? '1.0' : '0.5' + return `${content}` + }) + .join('
') + +const createTextShadow = (color, size = 1, times = 1) => { + const shadows = [] + for (let i = 0; i < times; i++) { + shadows.push(`0px 0px ${size}px ${color}`) + } + return shadows.join(', ') +} + +const getLabelStyleConfig = (mapType) => { + const configs = { + [MapType.Hybrid]: { + fontWeight: 500, + color: theme.background, + textShadow: createTextShadow(theme.headerText, 2, 5), + backgroundColor: 'unset', + }, + } + + return ( + configs[mapType] || { + fontWeight: 500, + color: theme.secondaryText, + textShadow: createTextShadow(theme.background, 2, 10), + backgroundColor: 'unset', + } + ) +} + +const setLabelTextStyle = (div, mapType) => { + const config = getLabelStyleConfig(mapType) + Object.keys(config).forEach((key) => { + div.style[key] = config[key] + }) +} + +const createBaseLabelDiv = (isSaved) => { + const div = document.createElement('div') + div.style.position = 'absolute' + div.style.padding = '4px 8px' + div.style.fontSize = '12px' + div.style.pointerEvents = 'none' + div.style.marginTop = '5px' + div.style.textAlign = 'center' + div.style.display = 'block' + div.style.zIndex = isSaved ? Z_INDEX.SAVED : Z_INDEX.DEFAULT + return div +} + +// Create an OverlayView label for a location. `mapOrPanorama` may be a Map or a +// StreetViewPanorama; OverlayView supports both and projects accordingly. +export const createLabel = ( + google, + mapOrPanorama, + location, + labelHtml, + isHovered, + mapType, + isSaved, +) => { + const label = new google.OverlayView() + label.position = new google.LatLng(location.lat, location.lng) + label.labelHtml = labelHtml + label.locationId = location.id + label.overlayLayerPane = null + label.overlayMouseTargetPane = null + label.isHovered = isHovered + label.mapType = mapType + label.isSaved = isSaved + + label.onAdd = function () { + const div = createBaseLabelDiv(this.isSaved) + setLabelTextStyle(div, this.mapType) + div.innerHTML = this.labelHtml + + this.div = div + const panes = this.getPanes() + this.overlayLayerPane = panes.overlayLayer + this.overlayMouseTargetPane = panes.overlayMouseTarget + const targetPane = this.isHovered + ? this.overlayMouseTargetPane + : this.overlayLayerPane + targetPane.appendChild(div) + } + + label.draw = function () { + const projection = this.getProjection() + const position = projection.fromLatLngToDivPixel(this.position) + + const div = this.div + div.style.left = `${position.x}px` + div.style.top = `${position.y}px` + div.style.transform = 'translate(-50%, 0)' + } + + label.onRemove = function () { + if (this.div) { + this.div.parentNode.removeChild(this.div) + this.div = null + } + } + + label.moveToPane = function (isHovered) { + if (!this.div || !this.overlayLayerPane || !this.overlayMouseTargetPane) { + return + } + const targetPane = isHovered + ? this.overlayMouseTargetPane + : this.overlayLayerPane + if (this.div.parentNode !== targetPane) { + targetPane.appendChild(this.div) + } + } + + label.updateStyle = function (mapType) { + if (!this.div) { + return + } + this.mapType = mapType + setLabelTextStyle(this.div, mapType) + } + + label.updatePosition = function (google, lat, lng) { + this.position = new google.LatLng(lat, lng) + this.draw() + } + + label.updateHtml = function (newHtml) { + if (!this.div || this.labelHtml === newHtml) { + return + } + this.labelHtml = newHtml + this.div.innerHTML = newHtml + } + + label.setMap(mapOrPanorama) + return label +} + +export const getMarkerIcon = (google, isSaved) => ({ + url: isSaved ? '/saved_location_dot.svg' : '/location_blue_dot.svg', + anchor: new google.Point(8, 8), + scaledSize: new google.Size(16, 16), +}) diff --git a/src/utils/getDisplayLabel.js b/src/utils/getDisplayLabel.js new file mode 100644 index 000000000..9ed85f3d8 --- /dev/null +++ b/src/utils/getDisplayLabel.js @@ -0,0 +1,52 @@ +/** + * Resolve the display label for a location type id. + * + * Returns an object describing the best human-readable name for the type + * (common name, cultivar, or scientific name), or null when nothing suitable + * is available. Shared by the map markers and the Street View panorama marker. + * + * @param {object} typesAccess - the typesAccess helper from the type slice + * @param {number} id - the type id to label + * @returns {{text: string, isScientific: boolean, typeId: number}|null} + */ +export const getDisplayLabel = (typesAccess, id) => { + const type = typesAccess.getType(id) + if (!type) { + return null + } + + if (type.cultivar) { + const parentType = typesAccess.getParentType(id) + if ( + parentType && + parentType.commonName && + parentType.scientificName && + (!type.commonName || + type.commonName.toLowerCase() === parentType.commonName.toLowerCase()) + ) { + return { + text: `${parentType.commonName} '${type.cultivar}'`, + isScientific: false, + typeId: id, + } + } + } + + if (type.commonName) { + return { + text: type.commonName, + isScientific: false, + typeId: id, + } + } + + if (type.scientificName) { + return { + text: type.scientificName, + isScientific: true, + typeId: id, + } + } + + return null +}