From 26f5606b1d79fad11dc3a9f1ad37bcfe775ce80d Mon Sep 17 00:00:00 2001 From: "Samuel T." Date: Wed, 27 Nov 2024 21:23:40 -0500 Subject: [PATCH] Map page cross-linking (#310) * Focus on Canopeum if use location unavailable * Zoom to center of all sites by default * Select pins from clicking map + visit site link * Fix map-page scrolling * fix missing scrollbar in general --- .../src/assets/styles/GlobalStyles.scss | 8 +- .../src/components/auth/AuthPageLayout.tsx | 11 +- .../src/components/social/PostCard.tsx | 4 +- canopeum_frontend/src/locale/en/social.ts | 1 + canopeum_frontend/src/locale/fr/social.ts | 1 + canopeum_frontend/src/pages/MapPage.scss | 40 ++--- canopeum_frontend/src/pages/MapPage.tsx | 159 +++++++++++++----- .../src/pages/PostDetailsPage.tsx | 4 +- pyrightconfig.json | 2 +- 9 files changed, 144 insertions(+), 86 deletions(-) diff --git a/canopeum_frontend/src/assets/styles/GlobalStyles.scss b/canopeum_frontend/src/assets/styles/GlobalStyles.scss index 18e550a39..4090b8701 100644 --- a/canopeum_frontend/src/assets/styles/GlobalStyles.scss +++ b/canopeum_frontend/src/assets/styles/GlobalStyles.scss @@ -1,4 +1,3 @@ -@use '../../../node_modules/bootstrap/scss/bootstrap.scss'; @use 'Variables.scss' as *; // TODO (Sam. T): Move this file to a GlobalStyles/_index.scss and imported files into same folder @@ -10,16 +9,15 @@ @use 'Transitions.scss'; ::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 0.5em; + height: 0.5em; } - ::-webkit-scrollbar-track { background-color: transparent; } ::-webkit-scrollbar-thumb { - background-color: map-get(bootstrap.$colors, dark-gray); + background-color: $gray-600; // map-get($colors, gray) border-radius: 5px; } diff --git a/canopeum_frontend/src/components/auth/AuthPageLayout.tsx b/canopeum_frontend/src/components/auth/AuthPageLayout.tsx index f09cd9740..a795cade3 100644 --- a/canopeum_frontend/src/components/auth/AuthPageLayout.tsx +++ b/canopeum_frontend/src/components/auth/AuthPageLayout.tsx @@ -29,13 +29,14 @@ const AuthPageLayout = ({ children }: Props) => { overflow-y-auto '> {children} - - + + arrow_back - - {translate('auth.back-to-map')} - + {translate('auth.back-to-map')} diff --git a/canopeum_frontend/src/components/social/PostCard.tsx b/canopeum_frontend/src/components/social/PostCard.tsx index 5a1d2d923..16440a7cd 100644 --- a/canopeum_frontend/src/components/social/PostCard.tsx +++ b/canopeum_frontend/src/components/social/PostCard.tsx @@ -90,7 +90,7 @@ const PostCard = ({ post, showActions }: Props) => {
- + site {
- {post.site.name} + {post.site.name}
{formatDate(post.createdAt, { dateStyle: 'short' })} diff --git a/canopeum_frontend/src/locale/en/social.ts b/canopeum_frontend/src/locale/en/social.ts index 165fe2659..7c9676373 100644 --- a/canopeum_frontend/src/locale/en/social.ts +++ b/canopeum_frontend/src/locale/en/social.ts @@ -4,6 +4,7 @@ export default { unfollow: 'Unfollow', public: 'Public', }, + 'visit-site': 'Visit the Site', comments: { 'leave-a-comment': 'Leave a Comment', word_one: 'word', diff --git a/canopeum_frontend/src/locale/fr/social.ts b/canopeum_frontend/src/locale/fr/social.ts index f54d5db37..8b4b1a0e8 100644 --- a/canopeum_frontend/src/locale/fr/social.ts +++ b/canopeum_frontend/src/locale/fr/social.ts @@ -6,6 +6,7 @@ export default { unfollow: 'Ne plus suivre', public: 'Publique', }, + 'visit-site': 'Visiter le Site', comments: { 'leave-a-comment': 'Laisser un Commentaire', word_one: 'mot', diff --git a/canopeum_frontend/src/pages/MapPage.scss b/canopeum_frontend/src/pages/MapPage.scss index 62cd1003d..80d00eaf8 100644 --- a/canopeum_frontend/src/pages/MapPage.scss +++ b/canopeum_frontend/src/pages/MapPage.scss @@ -1,51 +1,31 @@ @use '../assets/styles/Variables.scss' as *; -#map-page-row-container { - padding: 0 2rem; -} - -#map-sites-list-container { - padding: 0; -} - .map-site-image { border-radius: $border-radius $border-radius 0 0; width: 100%; } -@media screen and (min-width: $medium-width) { - #map-page-row-container { - padding: 0 4rem; - } +#map-container { + padding: 0; +} +#map-page-row-container { + height: calc(100vh - 62px); +} +#map-container { + height: calc(100vh - 62px); } @media screen and (max-width: $large-width) { + #map-sites-list-container, #map-container { - height: calc(100vh - 3.3rem); - margin-top: 2rem; + padding: 0 3rem; } } @media screen and (min-width: $large-width) { #map-sites-list-container { overflow-y: auto; - padding: 1rem; - } - - #map-page-row-container { - padding: 0; } - - #map-container { - height: 100%; - margin: 0; - margin-top: 0; - } - - #map-page-row-container { - height: calc(100vh - 4rem); - } - .map-site-image { border-radius: $border-radius 0 0 $border-radius; width: unset; diff --git a/canopeum_frontend/src/pages/MapPage.tsx b/canopeum_frontend/src/pages/MapPage.tsx index 681c2d11b..2bb8ec775 100644 --- a/canopeum_frontend/src/pages/MapPage.tsx +++ b/canopeum_frontend/src/pages/MapPage.tsx @@ -1,6 +1,9 @@ import './MapPage.scss' -import { useCallback, useEffect, useState } from 'react' +import type { Marker as MarkerInstance } from 'maplibre-gl' +import { type CSSProperties, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { MarkerEvent } from 'react-map-gl/dist/esm/types' import ReactMap, { GeolocateControl, Marker, NavigationControl, ScaleControl, type ViewState } from 'react-map-gl/maplibre' import { Link } from 'react-router-dom' @@ -11,41 +14,82 @@ import { getSiteTypeIconKey, type SiteTypeID } from '@models/SiteType' import type { SiteMap } from '@services/api' import { getApiBaseUrl } from '@services/apiSettings' -type MarkerEvent = { - target: { - _lngLat: { - lat: number, - lng: number, +const PIN_FOCUS_ZOOM_LEVEL = 15 +const MAP_DISTANCE_ZOOM_MULTIPLIER = 20 + +/** + * The initial map location if the user doesn't provide location + */ +const initialMapLocation = (sites: SiteMap[]) => { + // eslint-disable-next-line unicorn/no-array-reduce -- Find the middle point between all sites + const { minLat, maxLat, minLong, maxLong } = sites.reduce( + (previous, current) => { + // Unset or invalid coordinate should be ignored when trying to pin the center of all sites + if (!current.coordinates.latitude || !current.coordinates.longitude) return previous + + return { + minLat: Math.min(previous.minLat, current.coordinates.latitude), + maxLat: Math.max(previous.maxLat, current.coordinates.latitude), + minLong: Math.min(previous.minLong, current.coordinates.longitude), + maxLong: Math.max(previous.maxLong, current.coordinates.longitude), + } }, - }, + { minLat: 90, maxLat: -90, minLong: 180, maxLong: -180 }, + ) + + return { + // Center the map to the middle point between all sites + latitude: (maxLat + minLat) / 2, + longitude: (maxLong + minLong) / 2, + // min to take the most zoomed out between latitude or longitude + zoom: Math.min( + // 0 is max zoomed out, so we use an "inverse" (1 / x) + // The bigger the distance (max - min), the lower the zoom + (1 / (maxLat - minLat)) * MAP_DISTANCE_ZOOM_MULTIPLIER, + (1 / (maxLong - minLong)) * MAP_DISTANCE_ZOOM_MULTIPLIER, + ), + } } const MapPage = () => { const { getApiClient } = useApiClient() - + const { t } = useTranslation() const [sites, setSites] = useState([]) const [selectedSiteId, setSelectedSiteId] = useState() const [mapViewState, setMapViewState] = useState({ - longitude: -100, - latitude: 40, - zoom: 5, + longitude: 0, + latitude: 0, + zoom: PIN_FOCUS_ZOOM_LEVEL, }) const fetchData = useCallback(async () => { const response = await getApiClient().siteClient.map() setSites(response) + return response }, [getApiClient]) - const onMarkerClick = (event: MarkerEvent, site: SiteMap) => { - const { lat, lng } = event.target._lngLat + const onSelectSite = ( + site: SiteMap, + mapMarkerEvent?: MarkerEvent, + ) => { + const latitude = mapMarkerEvent?.target._lngLat.lat ?? site.coordinates.latitude + const longitude = mapMarkerEvent?.target._lngLat.lng ?? site.coordinates.longitude + if (!latitude || !longitude) return + setMapViewState({ - latitude: lat, - longitude: lng, - zoom: 15, + latitude, + longitude, + zoom: PIN_FOCUS_ZOOM_LEVEL, }) setSelectedSiteId(site.id) - document.getElementById(`${site.id}`)?.scrollIntoView({ behavior: 'smooth' }) + if (mapMarkerEvent) { + // Clicked from map, scroll card into view + document.getElementById(`site-card-${site.id}`)?.scrollIntoView({ behavior: 'smooth' }) + } else { + // Clicked from card, scroll to top for mobile + window.scrollTo({ behavior: 'smooth', top: 0 }) + } } const onMapMove = (viewState: ViewState) => { @@ -53,22 +97,40 @@ const MapPage = () => { setSelectedSiteId(undefined) } - useEffect(() => { - void fetchData() - - /* eslint-disable-next-line sonarjs/no-intrusive-permissions - -- We only ask when the map is rendered */ - navigator.geolocation.getCurrentPosition(position => { - const { latitude, longitude } = position.coords - setMapViewState(mvs => ({ ...mvs, latitude, longitude })) - }) - }, [fetchData]) + useEffect(() => + void Promise.all([ + fetchData(), + new Promise( + ( + resolve: (position: GeolocationPosition | GeolocationPositionError) => void, + _reject, + ): void => { + /* eslint-disable-next-line sonarjs/no-intrusive-permissions + -- We only ask when the map is rendered */ + navigator.geolocation.getCurrentPosition(resolve, resolve) + }, + ), + ]).then(([fetchedSites, position]) => + 'code' in position + // If there's an error obtaining the user position, use our default position instead + // Note that getCurrentPosition always error code 2 in http + ? setMapViewState(mvs => ({ + ...mvs, + ...initialMapLocation(fetchedSites), + })) + // Otherwise focus on the user's position + : setMapViewState(mvs => ({ + ...mvs, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + })) + ), [fetchData]) return ( -
+
{ key={`${site.id}-${latitude}-${longitude}`} latitude={latitude} longitude={longitude} - onClick={event => onMarkerClick(event, site)} + onClick={event => onSelectSite(site, event)} style={{ cursor: 'pointer' }} > @@ -100,24 +162,23 @@ const MapPage = () => {
-
-
+
+
{sites.map(site => (
- onSelectSite(site)} + type='button' >
@@ -143,10 +204,26 @@ const MapPage = () => { location_on {site.coordinates.address} + + + {t('social.visit-site')} + + arrow_forward + +
- +
))}
diff --git a/canopeum_frontend/src/pages/PostDetailsPage.tsx b/canopeum_frontend/src/pages/PostDetailsPage.tsx index b8d3b940c..a1410b063 100644 --- a/canopeum_frontend/src/pages/PostDetailsPage.tsx +++ b/canopeum_frontend/src/pages/PostDetailsPage.tsx @@ -75,10 +75,10 @@ const PostDetailsPage = () => { return (
- arrow_back + arrow_back {translate('posts.back-to-social')} diff --git a/pyrightconfig.json b/pyrightconfig.json index 4018af55b..f67779ba0 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,4 +1,4 @@ -// Must be set at root for pylance to pickup. Can't be in cannopeum_backend/pyproject.toml. bleh +// Must be set at root for pylance to pickup. Can't be in canopeum_backend/pyproject.toml. bleh // https://github.com/microsoft/pyright/blob/main/docs/configuration.md#sample-pyprojecttoml-file { "pythonVersion": "3.12",