Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calculate viewport bounds when searching for a place #543

Merged
merged 5 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/map-view-redux.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Usage:
1. **Page Loads**
1. Default or URL-parsed center and zoom are passed.
2. `GoogleMapReact` calls `onChange` to provide map bounds.
3. `MapPage` dispatches `viewChangeAndFetch` with the new view:
3. `MapPage` dispatches actions with the new view:
- Updates URL.
- Stops tracking geolocation if the user moved too far.
- Fetches filter counts if the filter is open.
Expand Down
9 changes: 7 additions & 2 deletions src/components/map/MapPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { fetchFilterCounts } from '../../redux/filterSlice'
import { updatePosition } from '../../redux/locationSlice'
import { setGoogle } from '../../redux/mapSlice'
import { fetchLocations, viewChangeAndFetch } from '../../redux/viewChange'
import { fetchLocations } from '../../redux/viewChange'
import { updateLastMapView } from '../../redux/viewportSlice'
import { bootstrapURLKeys } from '../../utils/bootstrapURLKeys'
import throttle from '../../utils/throttle'
Expand Down Expand Up @@ -117,9 +117,12 @@ const makeHandleViewChange = (dispatch, googleMap, history) => (_) => {
center: { lat: center.lat(), lng: center.lng() },
zoom: googleMap.getZoom(),
bounds: googleMap.getBounds().toJSON(),
width: googleMap.getDiv().offsetWidth,
height: googleMap.getDiv().offsetHeight,
}
dispatch(viewChangeAndFetch(newView))
dispatch(updateLastMapView(newView))
dispatch(fetchLocations())
dispatch(fetchFilterCounts())
history.changeView(newView)
}

Expand Down Expand Up @@ -222,6 +225,8 @@ const MapPage = ({ isDesktop }) => {
center: { lat: center.lat(), lng: center.lng() },
zoom: map.getZoom(),
bounds: map.getBounds().toJSON(),
width: map.getDiv().offsetWidth,
height: map.getDiv().offsetHeight,
}
dispatch(updateLastMapView(initialView))
dispatch(fetchLocations())
Expand Down
4 changes: 3 additions & 1 deletion src/components/search/Search.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const Search = (props) => {
})

const { googleMap } = useSelector((state) => state.map)
const { lastMapView } = useSelector((state) => state.viewport)

const coordinatesResultOrNull = getCoordinatesResult(value)
const suggestionsList = coordinatesResultOrNull
Expand Down Expand Up @@ -153,13 +154,14 @@ const Search = (props) => {
const longitude = Number(description.split(',')[1])
dispatch(
selectPlace({
place: getZoomedInView(latitude, longitude),
place: getZoomedInView(latitude, longitude, lastMapView),
}),
)
} else {
const placeBounds = await getPlaceBounds(
description,
descriptionToPlaceId.current[description],
lastMapView,
)
dispatch(selectPlace({ place: placeBounds }))
}
Expand Down
9 changes: 2 additions & 7 deletions src/redux/mapSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,8 @@ export const mapSlice = createSlice({
}
},
[selectPlace]: (state, action) => {
const { ne, sw } = action.payload.place.viewport
const maps = state.getGoogleMaps()
const bounds = new maps.LatLngBounds(
{ lat: sw.lat, lng: sw.lng },
{ lat: ne.lat, lng: ne.lng },
)
state.googleMap.fitBounds(bounds)
state.googleMap.setCenter(action.payload.place.view.center)
state.googleMap.setZoom(action.payload.place.view.zoom)
},
},
})
Expand Down
6 changes: 0 additions & 6 deletions src/redux/viewChange.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { VISIBLE_CLUSTER_ZOOM_LIMIT } from '../constants/map'
import { fetchFilterCounts } from './filterSlice'
import { fetchMapClusters, fetchMapLocations } from './mapSlice'
import { updateSelection } from './updateSelection'
import { updateLastMapView } from './viewportSlice'

const getIsShowingClusters = (state) => {
const map = state.map.googleMap
Expand All @@ -22,11 +21,6 @@ export const fetchLocations = () => (dispatch, getState) => {
}
}

export const viewChangeAndFetch = (newView) => (dispatch) => {
dispatch(updateLastMapView(newView))
dispatch(fetchLocations())
dispatch(fetchFilterCounts())
}
export const filtersChanged = (filters) => (dispatch) => {
dispatch(updateSelection(filters))
dispatch(fetchFilterCounts())
Expand Down
15 changes: 14 additions & 1 deletion src/redux/viewportSlice.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { createSlice } from '@reduxjs/toolkit'

import { selectPlace } from './placeSlice'

export const viewportSlice = createSlice({
name: 'viewport',
initialState: {
lastMapView: null,
},
reducers: {
updateLastMapView: (state, action) => {
state.lastMapView = action.payload
if (action.payload.width > 0 && action.payload.height > 0) {
state.lastMapView = action.payload
}
},
},
extraReducers: {
[selectPlace]: (state, action) => {
state.lastMapView = {
height: state.lastMapView.height,
width: state.lastMapView.width,
...action.payload.place.view,
}
},
},
})
Expand Down
155 changes: 141 additions & 14 deletions src/utils/viewportBounds.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,164 @@
import { getGeocode } from 'use-places-autocomplete'

const BOUND_DELTA = 0.001
export const getZoomedInView = (locationLat, locationLng, lastMapView) => {
const center = { lat: locationLat, lng: locationLng }
const zoom = 17
const bounds = getBoundsForScreenSize(
center,
zoom,
lastMapView.width,
lastMapView.height,
)

export const getZoomedInView = (locationLat, locationLng) =>
// Use fixed zoom level locationed at lat and long
({
return {
location: {
lat: locationLat,
lng: locationLng,
description: `${locationLat}, ${locationLng}`,
},
viewport: {
ne: { lat: locationLat + BOUND_DELTA, lng: locationLng + BOUND_DELTA },
sw: { lat: locationLat - BOUND_DELTA, lng: locationLng - BOUND_DELTA },
},
})
view: { bounds, center, zoom },
}
}

const TILE_SIZE = 256
const ZOOM_MAX = 21 // max zoom level in Google Maps

const latRad = (lat) => {
const sin = Math.sin((lat * Math.PI) / 180)
const radX2 = Math.log((1 + sin) / (1 - sin)) / 2
return Math.max(Math.min(radX2, Math.PI), -Math.PI) / 2
}

const getZoom = (north, east, south, west, height, width) => {
const northRad = latRad(north)
const southRad = latRad(south)
const latFraction = Math.abs(northRad - southRad) / Math.PI

let lngDiff = east - west
if (lngDiff < -180) {
lngDiff += 360
}
if (lngDiff > 180) {
lngDiff -= 360
}
const lngFraction = Math.abs(lngDiff) / 360

if (latFraction === 0 || lngFraction === 0) {
return 12 // Avoid division by zero error
}

const latZoom = Math.floor(
Math.log(height / TILE_SIZE / latFraction) / Math.LN2,
)
const lngZoom = Math.floor(
Math.log(width / TILE_SIZE / lngFraction) / Math.LN2,
)

return Math.min(Math.max(1, Math.min(latZoom, lngZoom)), ZOOM_MAX)
}

export const getPlaceBounds = async (description, placeId) => {
const EARTH_RADIUS = 6378137
const MAX_LATITUDE = 85.0511287798

export const getPlaceBounds = async (description, placeId, lastMapView) => {
const results = await getGeocode({ placeId })
const {
geometry: { viewport, location },
} = results[0]

const [ne, sw] = [viewport.getNorthEast(), viewport.getSouthWest()]

const mercatorNE = latLngToMercator(ne.lat(), ne.lng())
const mercatorSW = latLngToMercator(sw.lat(), sw.lng())
const mercatorCenter = {
x: (mercatorNE.x + mercatorSW.x) / 2,
y: (mercatorNE.y + mercatorSW.y) / 2,
}

const center = mercatorToLatLng(mercatorCenter.x, mercatorCenter.y)
const zoom = getZoom(
ne.lat(),
ne.lng(),
sw.lat(),
sw.lng(),
lastMapView.height,
lastMapView.width,
)
const bounds = getBoundsForScreenSize(
center,
zoom,
lastMapView.width,
lastMapView.height,
)

return {
location: {
lat: location.lat(),
lng: location.lng(),
description,
},
viewport: {
ne: { lat: ne.lat(), lng: ne.lng() },
sw: { lat: sw.lat(), lng: sw.lng() },
},
view: { bounds, center, zoom },
}
}

const latLngToMercator = (lat, lng) => {
const x = (lng * EARTH_RADIUS * Math.PI) / 180
let y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360)) * EARTH_RADIUS
y = Math.max(
-MAX_LATITUDE * EARTH_RADIUS,
Math.min(y, MAX_LATITUDE * EARTH_RADIUS),
)
return { x, y }
}

const mercatorToLatLng = (x, y) => {
const lng = (x * 180) / (EARTH_RADIUS * Math.PI)
const lat =
((2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2) * 180) / Math.PI
return { lat, lng }
}

const getBoundsForScreenSize = (center, zoom, width, height) => {
const scale = Math.pow(2, zoom)
const worldCoordinateCenter = project(center)

const pixelCoordinate = {
x: worldCoordinateCenter.x * scale,
y: worldCoordinateCenter.y * scale,
}

const halfWidthInPixels = width / 2
const halfHeightInPixels = height / 2

const newNorthEast = unproject({
x: (pixelCoordinate.x + halfWidthInPixels) / scale,
y: (pixelCoordinate.y - halfHeightInPixels) / scale,
})

const newSouthWest = unproject({
x: (pixelCoordinate.x - halfWidthInPixels) / scale,
y: (pixelCoordinate.y + halfHeightInPixels) / scale,
})

return {
south: newSouthWest.lat,
west: newSouthWest.lng,
north: newNorthEast.lat,
east: newNorthEast.lng,
}
}

const project = ({ lat, lng }) => {
const siny = Math.sin((lat * Math.PI) / 180)
const x = TILE_SIZE * (0.5 + lng / 360)
const y =
TILE_SIZE * (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI))
return { x, y }
}

const unproject = ({ x, y }) => {
const lng = (x / TILE_SIZE - 0.5) * 360
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / TILE_SIZE)))
const lat = latRadians * (180 / Math.PI)
return { lat, lng }
}
Loading