Skip to content
Draft
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
20 changes: 20 additions & 0 deletions app/components/map/facility_info_box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export function FacilityInfo({
selectedPoint,
onClose,
showGoToMapButton = true,
onNavigateTo,
navigateDisabled = false,
}: FacilityInfoProps) {
const navigate = useNavigate();
const { t } = useTranslation();
Expand Down Expand Up @@ -163,6 +165,24 @@ export function FacilityInfo({
)}

<div className="flex gap-2 mt-8 justify-end">
{onNavigateTo && (
<button
type="button"
disabled={navigateDisabled}
onClick={() => {
if (navigateDisabled) return;
onNavigateTo(selectedPoint);
onClose();
}}
className={`rounded-lg border px-4 py-2 text-sm font-semibold transition-colors ${
navigateDisabled
? 'cursor-not-allowed border-gray-200 text-gray-400 dark:border-zinc-700 dark:text-zinc-500'
: 'border-[#009DE0] text-[#009DE0] hover:bg-sky-50 dark:hover:bg-sky-900/20'
}`}
>
{t('map.navigate')}
</button>
)}
{isRestaurant && (
<button
type="button"
Expand Down
35 changes: 31 additions & 4 deletions app/components/map/map_component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,45 @@ import { useTranslation } from 'react-i18next';
export function MapComponent() {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const [locationState, setLocationState] = useState<'inside' | 'outside' | 'unavailable'>(
'unavailable'
);
const [centerUserAction, setCenterUserAction] = useState<(() => void) | null>(null);

return (
<div className="flex flex-col h-screen w-full dark:bg-transparent">
<TopBar isLoginPage={false} />
<div className="flex-1 relative">
<Map_proper searchQuery={searchQuery} />
<Map_proper
searchQuery={searchQuery}
onLocationStateChange={setLocationState}
onCenterUserActionChange={setCenterUserAction}
/>
<div className="absolute top-0 left-0 right-0 z-10">
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8 pt-1">
<section className="rounded-3xl border border-sky-100 dark:border-zinc-800 bg-white dark:bg-zinc-900 shadow-sm p-4 sm:p-5">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{t('map.pageTitle')}
</h1>
<div className="flex items-start justify-between gap-3">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{t('map.pageTitle')}
</h1>

{locationState === 'inside' && centerUserAction ? (
<button
type="button"
onClick={centerUserAction}
className="inline-flex shrink-0 rounded-full bg-amber-50/95 px-3 py-1 text-xs font-semibold text-amber-700 shadow-sm hover:bg-amber-100 dark:bg-amber-900/35 dark:text-amber-300 dark:hover:bg-amber-900/50"
>
{t('map.showMeWhereIAm')}
</button>
) : (
<span className="inline-flex shrink-0 rounded-lg bg-gray-200/90 px-3 py-1 text-xs font-semibold text-gray-700 shadow-sm dark:bg-zinc-700/40 dark:text-gray-200">
{locationState === 'outside'
? t('map.locationOutsideCampusNearby')
: t('map.locationUnavailable')}
</span>
)}
</div>

<p className="mt-2 max-w-2xl text-sm text-gray-600 dark:text-gray-300">
{t('map.pageSubtitle')}
</p>
Expand Down
219 changes: 217 additions & 2 deletions app/components/map/map_proper.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { GoogleMap, useJsApiLoader } from '@react-google-maps/api';
import { DirectionsRenderer, GoogleMap, Marker, useJsApiLoader } from '@react-google-maps/api';
import { useSearchParams } from 'react-router-dom';
import { zoom, mapStyles } from '~/components/map/map_config';

Expand All @@ -14,18 +14,30 @@ import { useGetAllVendingMachines } from '~/api/vending_machine_service';

interface MapProperProps {
searchQuery: string;
onLocationStateChange?: (state: 'inside' | 'outside' | 'unavailable') => void;
onCenterUserActionChange?: (action: (() => void) | null) => void;
}

const MAPS_LOADER_OPTIONS = {
id: 'jucaneat-google-maps-script',
googleMapsApiKey: import.meta.env.VITE_GOOGLE_MAPS_API_KEY ?? '',
} as const;

export function Map_proper({ searchQuery }: MapProperProps) {
export function Map_proper({
searchQuery,
onLocationStateChange,
onCenterUserActionChange,
}: MapProperProps) {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [selectedPlace, setSelectedPlace] = useState<Facility | null>(null);
const [mapCenter, setMapCenter] = useState<{ lat: number; lng: number } | null>(null);
const [userPosition, setUserPosition] = useState<{ lat: number; lng: number } | null>(null);
const [userMarkerPulseOn, setUserMarkerPulseOn] = useState(false);
const [userMarkerPulseKey, setUserMarkerPulseKey] = useState(0);
const [directionsResult, setDirectionsResult] = useState<google.maps.DirectionsResult | null>(
null
);

const { isLoaded, loadError } = useJsApiLoader(MAPS_LOADER_OPTIONS);

Expand Down Expand Up @@ -122,6 +134,100 @@ export function Map_proper({ searchQuery }: MapProperProps) {
east: lng + 0.02,
};

const isUserWithinBounds = (location: { lat: number; lng: number } | null) => {
if (!location) return false;
return (
location.lat >= bounds.south &&
location.lat <= bounds.north &&
location.lng >= bounds.west &&
location.lng <= bounds.east
);
};

const isUserOutsideCampus = Boolean(userPosition) && !isUserWithinBounds(userPosition);
const displayedUserLocation = !isUserOutsideCampus ? userPosition : null;
const isUserLocationUnavailable = !userPosition;

useEffect(() => {
if (isUserLocationUnavailable) {
onLocationStateChange?.('unavailable');
return;
}

if (isUserOutsideCampus) {
onLocationStateChange?.('outside');
return;
}

onLocationStateChange?.('inside');
}, [isUserLocationUnavailable, isUserOutsideCampus, onLocationStateChange]);

useEffect(() => {
if (!userPosition || isUserOutsideCampus) {
onCenterUserActionChange?.(null);
return;
}

onCenterUserActionChange?.(() => () => {
setMapCenter(userPosition);
setUserMarkerPulseKey(prev => prev + 1);
});
}, [userPosition, isUserOutsideCampus, onCenterUserActionChange]);

useEffect(() => {
if (userMarkerPulseKey === 0) return;

setUserMarkerPulseOn(true);

const timeouts = [
window.setTimeout(() => setUserMarkerPulseOn(false), 320),
window.setTimeout(() => setUserMarkerPulseOn(true), 640),
window.setTimeout(() => setUserMarkerPulseOn(false), 960),
];

return () => {
timeouts.forEach(timeout => window.clearTimeout(timeout));
};
}, [userMarkerPulseKey]);

useEffect(() => {
if (!primaryFacility || !navigator.geolocation) {
setUserPosition(null);
return;
}

// if (import.meta.env.DEV) {
// setUserPosition({
// lat: lat + 0.0015,
// lng: lng + 0.0015,
// });
// return;
// }

const watchId = navigator.geolocation.watchPosition(
position => {
const nextLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude,
};

setUserPosition(nextLocation);
},
() => {
setUserPosition(null);
},
{
enableHighAccuracy: true,
maximumAge: 10_000,
timeout: 10_000,
}
);

return () => {
navigator.geolocation.clearWatch(watchId);
};
}, [bounds.east, bounds.north, bounds.south, bounds.west, primaryFacility]);

if (restaurantsPending || vendingMachinesPending) {
return <MapLoadingScreen message={t('map.loading')} />;
}
Expand All @@ -135,6 +241,75 @@ export function Map_proper({ searchQuery }: MapProperProps) {
return <MapLoadingScreen message={t('map.loading')} />;
}

const handleNavigateToFacility = async (facility: Facility) => {
if (!userPosition || isUserOutsideCampus) {
return;
}

const destination = {
lat: facility.location.latitude.value,
lng: facility.location.longitude.value,
};

const origin = await new Promise<{ lat: number; lng: number } | null>(resolve => {
if (userPosition) {
resolve(userPosition);
return;
}

if (!navigator.geolocation) {
resolve(null);
return;
}

navigator.geolocation.getCurrentPosition(
position => {
const nextPosition = {
lat: position.coords.latitude,
lng: position.coords.longitude,
};
setUserPosition(nextPosition);
resolve(nextPosition);
},
() => resolve(null),
{
enableHighAccuracy: true,
timeout: 10_000,
maximumAge: 10_000,
}
);
});

if (!origin || !window.google?.maps) {
return;
}

const service = new window.google.maps.DirectionsService();

const result = await new Promise<google.maps.DirectionsResult | null>(resolve => {
service.route(
{
origin,
destination,
travelMode: window.google.maps.TravelMode.WALKING,
},
(response, status) => {
if (status === window.google.maps.DirectionsStatus.OK && response) {
resolve(response);
return;
}
resolve(null);
}
);
});

if (!result) {
return;
}

setDirectionsResult(result);
};

return (
<div className="relative w-full h-full">
<GoogleMap
Expand Down Expand Up @@ -166,11 +341,51 @@ export function Map_proper({ searchQuery }: MapProperProps) {
isHighlighted={selectedPlace?.id === vm.id || targetVendingMachineId === vm.id}
/>
))}

{directionsResult && (
<DirectionsRenderer
directions={directionsResult}
options={{
suppressMarkers: true,
preserveViewport: false,
polylineOptions: {
strokeColor: '#009DE0',
strokeOpacity: 0.9,
strokeWeight: 5,
},
}}
/>
)}

{displayedUserLocation && (
<Marker
position={displayedUserLocation}
clickable={false}
zIndex={1000}
icon={{
url:
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28"><circle cx="14" cy="14" r="13" fill="#F59E0B" stroke="#FFFFFF" stroke-width="2"/><circle cx="14" cy="10.2" r="3.1" fill="#FFFFFF"/><path d="M8.8 20.8c0-3.1 2.3-5.4 5.2-5.4s5.2 2.3 5.2 5.4v1.1H8.8z" fill="#FFFFFF"/></svg>'
),
scaledSize: new window.google.maps.Size(
userMarkerPulseOn ? 28 : 22,
userMarkerPulseOn ? 28 : 22
),
anchor: new window.google.maps.Point(
userMarkerPulseOn ? 14 : 11,
userMarkerPulseOn ? 14 : 11
),
}}
/>
)}
</GoogleMap>

<FacilityInfo
selectedPoint={selectedPlace}
showGoToMapButton={false}
onNavigateTo={handleNavigateToFacility}
navigateDisabled={!userPosition || isUserOutsideCampus}
onClose={() => {
setSelectedPlace(null);
if (targetRestaurantId || targetVendingMachineId) {
Expand Down
2 changes: 2 additions & 0 deletions app/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export interface FacilityInfoProps {
selectedPoint: Facility | null;
onClose: () => void;
showGoToMapButton?: boolean;
onNavigateTo?: (facility: Facility) => void;
navigateDisabled?: boolean;
}

export interface Dish {
Expand Down
Loading