diff --git a/src/components/Map/Map.tsx b/src/components/Map/Map.tsx index 4f46f46f..715f605c 100644 --- a/src/components/Map/Map.tsx +++ b/src/components/Map/Map.tsx @@ -4,14 +4,20 @@ import { useMap } from '@vis.gl/react-google-maps'; import { usePostHog } from 'posthog-js/react'; -import { type CSSProperties } from 'react'; +import { + type CSSProperties, + useState, + useCallback, + useRef, + useEffect +} from 'react'; import useIsMobile from 'hooks/useIsMobile'; import { CITY_HALL_LOCATION } from 'constants/defaults'; import { type ResourceEntry } from 'types/ResourceEntry'; import useSelectedResource from 'hooks/useSelectedResource'; -import useActiveResources from 'hooks/useActiveResources'; import useActiveSearchLocation from 'hooks/useActiveSearchLocation'; import ResourceMarker from 'components/ResourceMarker/ResourceMarker'; +import { getBathroomData } from 'services/db.ts'; const style: CSSProperties = { width: '100%', @@ -21,28 +27,125 @@ const style: CSSProperties = { touchAction: 'none' }; +const TIMELINE_PHASES = ['2024', 'Summer 2025', 'Fall 2025 - Jan 2026']; + const Map = () => { const isMobile = useIsMobile(); const posthog = usePostHog(); const { setSelectedResource } = useSelectedResource(); const { activeSearchLocation } = useActiveSearchLocation(); - const map = useMap(); - const { data: resources } = useActiveResources(); + const [dbData, setDbData] = useState<{ + part1: ResourceEntry[]; + part2: ResourceEntry[]; + part3: ResourceEntry[]; + }>({ part1: [], part2: [], part3: [] }); + const [isLoadingData, setIsLoadingData] = useState(true); - const onMarkerClick = (resource: ResourceEntry) => { - setSelectedResource(resource); + // --- ANIMATION STATE --- + const [visibleResources, setVisibleResources] = useState([]); + const [currentPhaseLabel, setCurrentPhaseLabel] = + useState('Ready to visualize'); + + const [currentPhaseIndex, setCurrentPhaseIndex] = useState(-1); + const [timelineProgressPercentage, setTimelineProgressPercentage] = + useState(0); // NEW: Granular progress + const [isPlaying, setIsPlaying] = useState(false); + const timeoutsRef = useRef([]); + + useEffect(() => { + const loadData = async () => { + try { + setIsLoadingData(true); + const data = await getBathroomData(); + setDbData(data); + } catch (error) { + console.error('Error loading water data:', error); + setCurrentPhaseLabel('Error loading data'); + } finally { + setIsLoadingData(false); + } + }; + + loadData(); + + return () => timeoutsRef.current.forEach(clearTimeout); + }, []); - if (!map) { - return; - } + // NEW: Added an onProgress callback so the timeline moves with every single marker + const staggerMarkers = ( + newResources: ResourceEntry[], + delayBetweenMarkers = 100, + onProgress: (phaseCompletionPercentage: number) => void + ): Promise => { + return new Promise(resolve => { + if (!newResources || newResources.length === 0) { + resolve(); + return; + } - map.panTo({ - lat: resource.latitude, - lng: resource.longitude + newResources.forEach((resource, index) => { + const timeout = setTimeout(() => { + setVisibleResources(prev => [...prev, resource]); + + // Report progress from 0.0 to 1.0 for this specific phase + onProgress((index + 1) / newResources.length); + + if (index === newResources.length - 1) { + resolve(); + } + }, index * delayBetweenMarkers); + timeoutsRef.current.push(timeout); + }); + }); + }; + + const playTimelapse = useCallback(async () => { + if (isPlaying || isLoadingData) return; + setIsPlaying(true); + setVisibleResources([]); + setTimelineProgressPercentage(0); + + const segmentWidth = 100 / TIMELINE_PHASES.length; // Each phase takes up 33.33% of the bar + + // Phase 1 + setCurrentPhaseIndex(0); + setCurrentPhaseLabel(TIMELINE_PHASES[0]); + await staggerMarkers(dbData.part1, 100, p => { + setTimelineProgressPercentage(0 * segmentWidth + p * segmentWidth); }); + await new Promise(r => setTimeout(r, 800)); + + // Phase 2 + setCurrentPhaseIndex(1); + setCurrentPhaseLabel(TIMELINE_PHASES[1]); + await staggerMarkers(dbData.part2, 80, p => { + setTimelineProgressPercentage(1 * segmentWidth + p * segmentWidth); + }); + + await new Promise(r => setTimeout(r, 800)); + + // Phase 3 + setCurrentPhaseIndex(2); + setCurrentPhaseLabel(TIMELINE_PHASES[2]); + await staggerMarkers(dbData.part3, 50, p => { + setTimelineProgressPercentage(2 * segmentWidth + p * segmentWidth); + }); + + setTimeout(() => { + setCurrentPhaseIndex(3); + setCurrentPhaseLabel('All Resources Mapped!'); + setTimelineProgressPercentage(100); + setIsPlaying(false); + }, 1500); + }, [isPlaying, isLoadingData, dbData]); + + const onMarkerClick = (resource: ResourceEntry) => { + setSelectedResource(resource); + if (!map) return; + map.panTo({ lat: resource.latitude, lng: resource.longitude }); posthog.capture('LocationClicked', { resourceType: resource.resource_type, name: resource.name, @@ -51,31 +154,179 @@ const Map = () => { }; return ( - - {resources?.map((resource, index) => ( - - ))} - - {activeSearchLocation ? ( - - ) : null} - +
+
+

+ {isLoadingData ? 'Loading Data...' : currentPhaseLabel} +

+ + {/* TIMELINE UI */} +
+ {/* Background Track */} +
+ + {/* Active Progress Track */} +
+ + {/* Timeline Nodes */} + {TIMELINE_PHASES.map((phase, index) => { + const isActive = currentPhaseIndex >= index; + // Place nodes exactly where the phase segments begin (0%, 33.3%, 66.6%) + const leftPos = `${index * (100 / TIMELINE_PHASES.length)}%`; + + return ( +
+ ); + })} + + {/* Final "Complete" Node at 100% */} +
= 3 ? '#007BFF' : '#e0e0e0', + border: '3px solid white', + transition: 'background-color 0.3s ease-in-out', + boxShadow: + currentPhaseIndex >= 3 + ? '0 0 0 2px rgba(0, 123, 255, 0.2)' + : 'none' + }} + /> +
+ + {!isPlaying && !isLoadingData && ( + + )} +
+ + + {visibleResources.map((resource, index) => ( + + ))} + + {activeSearchLocation ? ( + + ) : null} + +
); }; diff --git a/src/services/db.ts b/src/services/db.ts index 64f3a8aa..3b21b633 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -3,7 +3,7 @@ import type { Provider, ResourceEntry } from 'types/ResourceEntry'; import type { ResourceTypeOption } from 'hooks/useResourceType'; import type { Contributor } from 'types/Contributor'; import type { FeedbackForm } from 'types/FeedbackEntry'; -import { env } from 'config'; +import { env } from 'config.ts'; // Need access to the database? Please refer to .example.env and message us in the #phlask-data channel on Slack const databaseUrl = 'https://wantycfbnzzocsbthqzs.supabase.co'; @@ -12,6 +12,9 @@ const resourceDatabaseName = 'resources'; const contributorDatabaseName = 'airtable_contributors'; const feedbackDatabaseName = 'user_feedbacks'; const providersDatabaseName = 'providers'; +const bathroom_part1 = 'bathroom_part1'; +const bathroom_part2 = 'bathroom_part2'; +const bathroom_part3 = 'bathroom_part3'; const supabase = createClient(databaseUrl, databaseApiKey); @@ -154,5 +157,35 @@ export const getResourceProviders = async ( return data; }; +export const getBathroomData = async () => { + const [part1Res, part2Res, part3Res] = await Promise.all([ + supabase.from(bathroom_part1).select('*'), + supabase.from(bathroom_part2).select('*'), + supabase.from(bathroom_part3).select('*') + ]); + + if (part1Res.error || part2Res.error || part3Res.error) { + throw new Error( + `Failed to fetch water data: ${ + part1Res.error?.message || + part2Res.error?.message || + part3Res.error?.message + }` + ); + } + + return { + part1: part1Res.data || [], + part2: part2Res.data || [], + part3: part3Res.data || [] + }; +}; + +const data = await getBathroomData(); + +console.log(`Part 1 has ${data.part1.length} items`); +console.log(`Part 2 has ${data.part2.length} items`); +console.log(`Part 3 has ${data.part3.length} items`); + export { supabase }; export default {};