@@ -17,6 +17,267 @@ let userLocationMarker = null;
1717// Global variable to track cluster markers
1818let clusterMarkers = [ ] ;
1919
20+ // Floating card state
21+ let cardModeEnabled = localStorage . getItem ( 'cardMode' ) !== 'false' ;
22+ let visibleEventsList = [ ] ;
23+ let currentCardIndex = - 1 ;
24+ const SWIPE_THRESHOLD = 50 ;
25+ const VELOCITY_THRESHOLD = 0.3 ; // px/ms
26+ const CARD_TRANSITION = 'transform 0.3s cubic-bezier(0.4,0,0.2,1)' ;
27+
28+ function updateCardToggleButton ( ) {
29+ const button = document . getElementById ( 'card-mode-toggle' ) ;
30+ if ( ! button ) return ;
31+ button . classList . toggle ( 'active' , cardModeEnabled ) ;
32+ button . title = cardModeEnabled ? 'Disable floating cards' : 'Enable floating cards' ;
33+ }
34+
35+ function buildCardContent ( container , event ) {
36+ const titleEl = container . querySelector ( '.event-card-title' ) ;
37+ titleEl . innerHTML = '' ;
38+ const titleLink = document . createElement ( 'a' ) ;
39+ titleLink . target = '_blank' ;
40+ titleLink . href = event . url ;
41+ titleLink . textContent = event . title ;
42+ titleEl . appendChild ( titleLink ) ;
43+
44+ const metaEl = container . querySelector ( '.event-card-meta' ) ;
45+ metaEl . innerHTML = '' ;
46+ const venueLink = document . createElement ( 'a' ) ;
47+ venueLink . target = '_blank' ;
48+ venueLink . href = `https://maps.google.com/?q=${ encodeURIComponent ( event . venue ) } &ll=${ event . geometry . lat } ,${ event . geometry . lng } ` ;
49+ venueLink . textContent = event . venue ;
50+ metaEl . appendChild ( venueLink ) ;
51+ metaEl . appendChild ( document . createTextNode ( ` | ${ event . date_text } | ${ event . time } ` ) ) ;
52+
53+ const costEl = container . querySelector ( '.event-card-cost' ) ;
54+ costEl . innerHTML = '' ;
55+ const costLink = document . createElement ( 'a' ) ;
56+ costLink . target = '_blank' ;
57+ costLink . href = event . eventUrl ;
58+ costLink . textContent = event . cost ;
59+ costEl . appendChild ( costLink ) ;
60+ if ( event . cost_details ) {
61+ costEl . appendChild ( document . createTextNode ( ' — ' + event . cost_details ) ) ;
62+ }
63+ const calendarContainer = container . querySelector ( '#event-card-calendar-container' ) ;
64+ if ( calendarContainer ) {
65+ const time = event . time . split ( ' to ' ) ;
66+ const start = new Date ( `${ event . date_text } ${ time [ 0 ] } ` ) ;
67+ const startDate = start . toLocaleDateString ( 'sv-SE' ) ;
68+ let endToken ;
69+ if ( time [ 1 ] ) {
70+ if ( time [ 1 ] . substr ( - 2 ) == 'am' && time [ 0 ] . substr ( - 2 ) == 'pm' ) {
71+ const endDate = new Date ( start ) ;
72+ endDate . setDate ( endDate . getDate ( ) + 1 ) ;
73+ endToken = `${ endDate . toLocaleDateString ( 'sv-SE' ) . replace ( / - / gi, '/' ) } ${ time [ 1 ] } ` ;
74+ } else {
75+ endToken = `${ startDate . replace ( / - / gi, '/' ) } ${ time [ 1 ] } ` ;
76+ }
77+ } else {
78+ endToken = start . getTime ( ) + 60 * 60 * 1000 ;
79+ }
80+ const end = new Date ( endToken ) ;
81+ calendarContainer . innerHTML = `<add-to-calendar-button
82+ name="${ event . title . replaceAll ( '"' , "'" ) } "
83+ description="${ event . eventUrl } "
84+ startDate="${ startDate } "
85+ options="'Apple','Google','iCal','Outlook.com'"
86+ startTime="${ start . toTimeString ( ) . substr ( 0 , 5 ) } "
87+ endDate="${ end . toLocaleDateString ( 'sv-SE' ) } "
88+ endTime="${ end . toTimeString ( ) . substr ( 0 , 5 ) } "
89+ location="${ event . venue } "
90+ listStyle="modal"
91+ buttonStyle="default"
92+ timeZone="America/Los_Angeles"
93+ size="4"
94+ hideTextLabelButton
95+ hideCheckmark
96+ forceOverlay
97+ ></add-to-calendar-button>` ;
98+ }
99+ }
100+
101+ function showEventCard ( event ) {
102+ currentCardIndex = visibleEventsList . indexOf ( event ) ;
103+ const current = document . getElementById ( 'event-card-current' ) ;
104+ current . style . transition = 'none' ;
105+ current . style . transform = 'translateX(0)' ;
106+ buildCardContent ( current , event ) ;
107+ document . getElementById ( 'event-card-counter' ) . textContent =
108+ `${ currentCardIndex + 1 } of ${ visibleEventsList . length } ` ;
109+ document . getElementById ( 'event-card' ) . classList . add ( 'visible' ) ;
110+ Events . infoWindow ( event ) . open ( window . map , event . marker ) ;
111+ }
112+
113+ function hideEventCard ( ) {
114+ document . getElementById ( 'event-card' ) . classList . remove ( 'visible' ) ;
115+ Events . infoWindow ( ) . close ( ) ;
116+ Events . currentEvent = null ;
117+ }
118+
119+ function commitNavigation ( direction ) {
120+ if ( ! visibleEventsList . length ) return ;
121+ const slider = document . getElementById ( 'event-card-slider' ) ;
122+ const current = document . getElementById ( 'event-card-current' ) ;
123+ const peek = document . getElementById ( 'event-card-peek' ) ;
124+ const width = slider . offsetWidth ;
125+ const newIndex = ( currentCardIndex + direction + visibleEventsList . length ) % visibleEventsList . length ;
126+
127+ buildCardContent ( peek , visibleEventsList [ newIndex ] ) ;
128+ peek . style . transition = 'none' ;
129+ current . style . transition = 'none' ;
130+ peek . style . transform = `translateX(${ direction > 0 ? width : - width } px)` ;
131+ current . style . transform = 'translateX(0)' ;
132+
133+ // Force reflow so transition fires
134+ peek . offsetWidth ;
135+
136+ peek . style . transition = CARD_TRANSITION ;
137+ current . style . transition = CARD_TRANSITION ;
138+ current . style . transform = `translateX(${ direction > 0 ? - width : width } px)` ;
139+ peek . style . transform = 'translateX(0)' ;
140+
141+ current . addEventListener ( 'transitionend' , ( ) => finishNavigation ( current , peek , newIndex ) , { once : true } ) ;
142+ }
143+
144+ function finishNavigation ( current , peek , newIndex ) {
145+ currentCardIndex = newIndex ;
146+ current . style . transition = 'none' ;
147+ peek . style . transition = 'none' ;
148+ current . style . transform = 'translateX(0)' ;
149+ peek . style . transform = '' ;
150+ buildCardContent ( current , visibleEventsList [ currentCardIndex ] ) ;
151+ document . getElementById ( 'event-card-counter' ) . textContent =
152+ `${ currentCardIndex + 1 } of ${ visibleEventsList . length } ` ;
153+ const ev = visibleEventsList [ currentCardIndex ] ;
154+ if ( ev ?. marker ) {
155+ window . map . panTo ( ev . geometry ) ;
156+ Events . infoWindow ( ev ) . open ( window . map , ev . marker ) ;
157+ }
158+ }
159+
160+ function initEventCard ( ) {
161+ const card = document . getElementById ( 'event-card' ) ;
162+ const slider = document . getElementById ( 'event-card-slider' ) ;
163+ const current = document . getElementById ( 'event-card-current' ) ;
164+ const peek = document . getElementById ( 'event-card-peek' ) ;
165+
166+ let touchStartX = 0 , touchStartY = 0 ;
167+ let lastMoveX = 0 , lastMoveTime = 0 , swipeVelocity = 0 ;
168+ let gestureAxis = null ; // 'horizontal' | 'vertical'
169+ let peekDirection = 0 ; // 1 = peek is on right (next), -1 = peek is on left (prev)
170+
171+ card . addEventListener ( 'touchstart' , e => {
172+ touchStartX = e . touches [ 0 ] . clientX ;
173+ touchStartY = e . touches [ 0 ] . clientY ;
174+ lastMoveX = touchStartX ;
175+ lastMoveTime = Date . now ( ) ;
176+ swipeVelocity = 0 ;
177+ gestureAxis = null ;
178+ peekDirection = 0 ;
179+ current . style . transition = 'none' ;
180+ peek . style . transition = 'none' ;
181+ } , { passive : true } ) ;
182+
183+ card . addEventListener ( 'touchmove' , e => {
184+ const deltaX = e . touches [ 0 ] . clientX - touchStartX ;
185+ const deltaY = e . touches [ 0 ] . clientY - touchStartY ;
186+
187+ if ( ! gestureAxis ) {
188+ if ( Math . abs ( deltaX ) > 8 ) gestureAxis = 'horizontal' ;
189+ else if ( Math . abs ( deltaY ) > 8 ) gestureAxis = 'vertical' ;
190+ else return ;
191+ }
192+ if ( gestureAxis !== 'horizontal' ) return ;
193+
194+ const now = Date . now ( ) ;
195+ const dt = now - lastMoveTime ;
196+ if ( dt > 0 ) swipeVelocity = ( e . touches [ 0 ] . clientX - lastMoveX ) / dt ;
197+ lastMoveX = e . touches [ 0 ] . clientX ;
198+ lastMoveTime = now ;
199+
200+ const width = slider . offsetWidth ;
201+ const newDir = deltaX < 0 ? 1 : - 1 ; // 1=next, -1=prev
202+
203+ if ( peekDirection !== newDir ) {
204+ peekDirection = newDir ;
205+ const peekIndex = ( currentCardIndex + newDir + visibleEventsList . length ) % visibleEventsList . length ;
206+ buildCardContent ( peek , visibleEventsList [ peekIndex ] ) ;
207+ peek . style . transition = 'none' ;
208+ peek . style . transform = `translateX(${ newDir > 0 ? width : - width } px)` ;
209+ }
210+
211+ current . style . transform = `translateX(${ deltaX } px)` ;
212+ peek . style . transform = `translateX(${ ( peekDirection > 0 ? width : - width ) + deltaX } px)` ;
213+ } , { passive : true } ) ;
214+
215+ card . addEventListener ( 'touchend' , e => {
216+ const deltaX = e . changedTouches [ 0 ] . clientX - touchStartX ;
217+ const deltaY = e . changedTouches [ 0 ] . clientY - touchStartY ;
218+
219+ if ( gestureAxis === 'vertical' ) {
220+ if ( deltaY > SWIPE_THRESHOLD ) hideEventCard ( ) ;
221+ return ;
222+ }
223+
224+ if ( gestureAxis !== 'horizontal' || ! peekDirection ) {
225+ return ;
226+ }
227+
228+ const width = slider . offsetWidth ;
229+ const shouldCommit = Math . abs ( deltaX ) > SWIPE_THRESHOLD || Math . abs ( swipeVelocity ) > VELOCITY_THRESHOLD ;
230+ const commitDir = deltaX < 0 ? 1 : - 1 ;
231+
232+ if ( shouldCommit ) {
233+ const newIndex = ( currentCardIndex + commitDir + visibleEventsList . length ) % visibleEventsList . length ;
234+ current . style . transition = CARD_TRANSITION ;
235+ peek . style . transition = CARD_TRANSITION ;
236+ current . style . transform = `translateX(${ commitDir > 0 ? - width : width } px)` ;
237+ peek . style . transform = 'translateX(0)' ;
238+
239+ current . addEventListener ( 'transitionend' , ( ) => finishNavigation ( current , peek , newIndex ) , { once : true } ) ;
240+ } else {
241+ current . style . transition = CARD_TRANSITION ;
242+ peek . style . transition = CARD_TRANSITION ;
243+ current . style . transform = 'translateX(0)' ;
244+ peek . style . transform = `translateX(${ peekDirection > 0 ? width : - width } px)` ;
245+ }
246+
247+ gestureAxis = null ;
248+ peekDirection = 0 ;
249+ } , { passive : true } ) ;
250+
251+ document . getElementById ( 'event-card-prev' ) . addEventListener ( 'click' , ( ) => commitNavigation ( - 1 ) ) ;
252+ document . getElementById ( 'event-card-next' ) . addEventListener ( 'click' , ( ) => commitNavigation ( 1 ) ) ;
253+ document . getElementById ( 'event-card-close' ) . addEventListener ( 'click' , hideEventCard ) ;
254+
255+ document . getElementById ( 'event-card-details' ) . addEventListener ( 'click' , ( ) => {
256+ const detailsEl = Events . infoWindow ( ) . getContent ( ) ?. querySelector ( 'details' ) ;
257+ if ( detailsEl ) detailsEl . open = ! detailsEl . open ;
258+ } ) ;
259+
260+ window . addEventListener ( 'keydown' , e => {
261+ if ( ! document . getElementById ( 'event-card' ) . classList . contains ( 'visible' ) ) return ;
262+ if ( e . key === 'ArrowLeft' ) { e . preventDefault ( ) ; commitNavigation ( - 1 ) ; }
263+ else if ( e . key === 'ArrowRight' ) { e . preventDefault ( ) ; commitNavigation ( 1 ) ; }
264+ } ) ;
265+
266+ const toggleButton = document . getElementById ( 'card-mode-toggle' ) ;
267+ updateCardToggleButton ( ) ;
268+ toggleButton . addEventListener ( 'click' , ( ) => {
269+ cardModeEnabled = ! cardModeEnabled ;
270+ localStorage . setItem ( 'cardMode' , cardModeEnabled ) ;
271+ updateCardToggleButton ( ) ;
272+ if ( cardModeEnabled && Events . currentEvent ) {
273+ showEventCard ( Events . currentEvent ) ;
274+ } else if ( ! cardModeEnabled ) {
275+ hideEventCard ( ) ;
276+ }
277+ } ) ;
278+ }
279+
280+
20281// Initialize the map
21282// window.addEventListener('load', initialize)
22283async function initialize ( ) {
@@ -42,12 +303,14 @@ async function initialize() {
42303 window . addEventListener ( 'keyup' , event => {
43304 switch ( event . keyCode ) {
44305 case 27 : // esc
45- Events . infoWindow ( ) . close ( ) ;
306+ Events . infoWindow ( ) . close ( ) ;
307+ hideEventCard ( ) ;
46308 break ;
47309 }
48310 } ) ;
49311 google . maps . event . addListener ( window . map , 'click' , function ( event ) {
50312 Events . infoWindow ( ) . close ( ) ;
313+ hideEventCard ( ) ;
51314 } ) ;
52315
53316 // Add "You Are Here" button
@@ -100,13 +363,19 @@ async function initialize() {
100363 content . style . setProperty ( '--delay-time' , time + 's' ) ;
101364 intersectionObserver . observe ( content ) ;
102365 event . marker . addListener ( 'gmp-click' , function ( ) {
103- Events . infoWindow ( event ) . open ( window . map , event . marker ) ;
366+ if ( cardModeEnabled ) {
367+ showEventCard ( event ) ;
368+ } else {
369+ Events . infoWindow ( event ) . open ( window . map , event . marker ) ;
370+ }
104371 } ) ;
105372 } ) ;
106373 const form = document . getElementById ( 'controls' ) ;
107374 form . addEventListener ( 'reset' , window . filter ) ;
108375 map . controls [ google . maps . ControlPosition . LEFT_BOTTOM ] . push ( form ) ;
109376 map . controls [ google . maps . ControlPosition . RIGHT_BOTTOM ] . push ( document . getElementById ( 'feedback' ) ) ;
377+ map . controls [ google . maps . ControlPosition . RIGHT_BOTTOM ] . push ( document . getElementById ( 'card-mode-toggle' ) ) ;
378+ initEventCard ( ) ;
110379 // Update the date picker
111380 if ( minDate && maxDate ) {
112381 form . elements [ 'date' ] . min = minDate . toLocaleDateString ( 'fr-ca' ) ;
@@ -325,6 +594,10 @@ window.filter = async function (filters = {}) {
325594 window . history . replaceState ( { } , '' , '?' + query . join ( '&' ) ) ;
326595 form . elements [ 'countEvents' ] . innerText = count ;
327596 form . elements [ 'countCategories' ] . innerText = categories . length || 'All' ;
597+ // Update the cached visible events list for card navigation
598+ visibleEventsList = window . events . get ( ) ?. filter ( e => e . visible && e . title && e . geometry ) || [ ] ;
599+ // Dismiss the card when filters change since the current event may no longer be visible
600+ hideEventCard ( ) ;
328601} ;
329602
330603/**
@@ -463,6 +736,7 @@ class Events {
463736 if ( ! this . cachedInfoWindow )
464737 this . cachedInfoWindow = new google . maps . InfoWindow ( { } ) ;
465738 if ( event ) {
739+ this . currentEvent = event ;
466740 const time = event . time . split ( ' to ' )
467741 const start = new Date ( `${ event . date_text } ${ time [ 0 ] } ` )
468742 const startDate = start . toLocaleDateString ( 'sv-SE' ) // outputs yyyy-mm-dd
0 commit comments