1- import { AfterViewInit , Component , ElementRef , OnDestroy , OnInit , ViewChild } from '@angular/core' ;
2- import { ActivatedRoute , NavigationEnd , Router } from '@angular/router' ;
1+ import { AfterViewInit , Component , ElementRef , OnInit , ViewChild } from '@angular/core' ;
32import { IonSearchbar , LoadingController } from '@ionic/angular' ;
4- import { Subscription } from 'rxjs' ;
5- import { filter } from 'rxjs/operators' ;
63
74import { ConferenceData } from '../providers/conference-data' ;
85
@@ -25,7 +22,7 @@ export interface BoothData {
2522 templateUrl : './expo-hall-map.component.html' ,
2623 styleUrls : [ './expo-hall-map.component.scss' ] ,
2724} )
28- export class ExpoHallMapComponent implements OnInit , AfterViewInit , OnDestroy {
25+ export class ExpoHallMapComponent implements OnInit , AfterViewInit {
2926 @ViewChild ( 'searchBar' ) searchBar ! : IonSearchbar ;
3027 @ViewChild ( 'pinchZoom' , { read : ElementRef } ) pinchZoomEl ?: ElementRef < HTMLElement > ;
3128 @ViewChild ( 'pinchZoom' ) pinchZoomCmp ?: {
@@ -126,8 +123,6 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy {
126123 constructor (
127124 private confData : ConferenceData ,
128125 private loadingCtrl : LoadingController ,
129- private route : ActivatedRoute ,
130- private router : Router ,
131126 ) { }
132127
133128 ngOnInit ( ) {
@@ -144,9 +139,19 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy {
144139 // popup instead of underneath it.
145140 private static readonly DEEPLINK_POPUP_OFFSET_PX = 60 ;
146141
147- private querySub ?: Subscription ;
148- private routerSub ?: Subscription ;
149- private lastZoomedBoothId : string | null = null ;
142+ // Booth id queued before pinch-zoom finishes initializing. ngAfterViewInit
143+ // polls for the IvyPinch instance, and the parent ConferenceMapPage may
144+ // call zoomToBoothId() before that polling completes (especially on cold
145+ // entry to the tab when image + pinch-zoom are still hydrating). We hold
146+ // the id here and apply it the moment pinch-zoom is ready.
147+ private pendingBoothId : string | null = null ;
148+ private pinchReady = false ;
149+ // Token incremented per zoom request so async work (image-load wait)
150+ // belonging to a superseded request can short-circuit instead of
151+ // clobbering the latest zoom — protects against the rare race where the
152+ // user taps two booth pills in fast succession before the floor-plan
153+ // image has finished loading.
154+ private zoomToken = 0 ;
150155
151156 ngAfterViewInit ( ) {
152157 // @ciag /ngx-pinch-zoom hardcodes defaultMaxScale=3 and only auto-derives
@@ -161,31 +166,12 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy {
161166 const inner = this . pinchZoomCmp ?. pinchZoom ;
162167 if ( inner ) {
163168 inner . maxScale = 25 ;
164- // React to the current ?booth=<id>, and to any future change while
165- // the component stays mounted (e.g. user pops back to a different
166- // sponsor and taps that booth's pill — Angular reuses this instance
167- // and only the query param changes).
168- this . querySub = this . route . queryParamMap . subscribe ( params => {
169- this . maybeZoomToQueryBooth ( params . get ( 'booth' ) ) ;
170- } ) ;
171- // Belt-and-braces: Ionic page caching keeps this component alive
172- // across nav, and ActivatedRoute.queryParamMap doesn't always
173- // re-emit when the cached page is re-entered with a new query
174- // string (sponsor → booth → back to sponsors → other sponsor →
175- // booth would otherwise leave us pinned to the first booth). On
176- // every navigation that lands on /expo-hall, parse the live URL
177- // (NOT route.snapshot, which is set on route activation and stays
178- // stale when Ionic just shows a cached page) and zoom if the
179- // booth id changed.
180- this . routerSub = this . router . events
181- . pipe ( filter ( ( e ) : e is NavigationEnd => e instanceof NavigationEnd ) )
182- . subscribe ( e => {
183- const url = e . urlAfterRedirects || this . router . url ;
184- if ( ! url . includes ( '/expo-hall' ) ) return ;
185- const tree = this . router . parseUrl ( url ) ;
186- const wantId = tree . queryParamMap . get ( 'booth' ) ;
187- this . maybeZoomToQueryBooth ( wantId ) ;
188- } ) ;
169+ this . pinchReady = true ;
170+ if ( this . pendingBoothId ) {
171+ const id = this . pendingBoothId ;
172+ this . pendingBoothId = null ;
173+ this . zoomToBoothId ( id ) ;
174+ }
189175 return ;
190176 }
191177 if ( Date . now ( ) - start < 2000 ) {
@@ -195,21 +181,36 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy {
195181 tick ( ) ;
196182 }
197183
198- ngOnDestroy ( ) {
199- this . querySub ?. unsubscribe ( ) ;
200- this . routerSub ?. unsubscribe ( ) ;
201- }
202-
203- private maybeZoomToQueryBooth ( wantId : string | null ) {
204- if ( ! wantId ) return ;
205- if ( wantId === this . lastZoomedBoothId ) return ; // already there
206- const booth = this . booths . find ( b => b . id === String ( wantId ) ) ;
184+ /**
185+ * Public entry point used by ConferenceMapPage to request a zoom-to-booth.
186+ * Driven by Ionic's ionViewWillEnter on the parent page so it fires
187+ * reliably on first nav, cached re-entry with a different ?booth=, the
188+ * same ?booth= twice in a row (re-centers if the user has panned away),
189+ * tab-switch return, and cold-start deeplinks.
190+ *
191+ * No `lastZoomedBoothId` guard: if the parent calls us, it's because the
192+ * user explicitly asked to see this booth — we should always re-center,
193+ * even if the id matches the previous zoom (the user may have panned).
194+ */
195+ zoomToBoothId ( boothId : string | null | undefined ) {
196+ if ( ! boothId ) return ;
197+ const id = String ( boothId ) ;
198+ if ( ! this . pinchReady ) {
199+ this . pendingBoothId = id ;
200+ return ;
201+ }
202+ const booth = this . booths . find ( b => b . id === id ) ;
207203 if ( ! booth ) return ;
208- this . lastZoomedBoothId = wantId ;
209- requestAnimationFrame ( ( ) => this . zoomToBooth ( booth ) ) ;
204+ const token = ++ this . zoomToken ;
205+ requestAnimationFrame ( ( ) => {
206+ // Bail if a newer request superseded us between the rAF schedule
207+ // and its callback (extremely unlikely but cheap to guard).
208+ if ( token !== this . zoomToken ) return ;
209+ this . zoomToBooth ( booth , token ) ;
210+ } ) ;
210211 }
211212
212- private async zoomToBooth ( booth : BoothData ) {
213+ private async zoomToBooth ( booth : BoothData , token ?: number ) {
213214 const inner = this . pinchZoomCmp ?. pinchZoom ;
214215 const host = this . pinchZoomEl ?. nativeElement ;
215216 if ( ! inner || ! host || ! host . offsetWidth ) return ;
@@ -228,6 +229,10 @@ export class ExpoHallMapComponent implements OnInit, AfterViewInit, OnDestroy {
228229 } ) ;
229230 }
230231
232+ // If a newer zoomToBoothId() arrived while we were waiting on the image,
233+ // abandon this stale request so it doesn't clobber the latest target.
234+ if ( token !== undefined && token !== this . zoomToken ) return ;
235+
231236 // We bypass IvyPinch.setZoom() because it always runs centeringImage() →
232237 // limitPanY() afterwards, and that clamp assumes the image fills the
233238 // host. Our floor plan PNG is wider than tall (W:H ≈ 1.41:1) inside a
0 commit comments