diff --git a/public/selected_location_pin.svg b/public/selected_location_pin.svg
new file mode 100644
index 00000000..b108db30
--- /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 e7442793..3dc2d8f6 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 a0805cf9..cb58403e 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 00000000..1b3dd9ca
--- /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 00000000..9ed85f3d
--- /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
+}