@@ -373,25 +373,40 @@ async function addToMap(item: EarthdataGisItem, onHint?: (hint: string) => void)
373373 await addWebMapToMap ( item , onHint ) ;
374374 return ;
375375 }
376- await addServiceToMap ( item , onHint ) ;
376+ await addServiceToMap ( item , { onHint } ) ;
377+ }
378+
379+ /** Options for {@link addServiceToMap}. */
380+ interface AddServiceOptions {
381+ /** Called when the view had to zoom past the extent to show data. */
382+ onHint ?: ( hint : string ) => void ;
383+ /**
384+ * Set when the layer comes from a web map, so it can be tracked and removed
385+ * together with its siblings.
386+ */
387+ webMapId ?: string ;
388+ /** Whether to move the view to the new layer. @default true */
389+ fit ?: boolean ;
390+ /**
391+ * An already-resolved minimum visible zoom, letting a caller that adds many
392+ * layers run the lookups concurrently. Omit to look it up here; `null` means
393+ * the lookup ran and found no constraint.
394+ */
395+ minVisibleZoom ?: number | null ;
377396}
378397
379398/**
380399 * Adds one ArcGIS service as a layer and returns its store id.
381400 *
382401 * @param item - A service item (never a web map)
383- * @param onHint - Called when the view had to zoom past the extent to show data
384- * @param webMapId - Set when the layer comes from a web map, so it can be
385- * tracked and removed together with its siblings
386- * @param fit - Whether to move the view to the new layer
402+ * @param options - Hint sink, web map ownership, view and zoom-lookup control
387403 * @returns The new layer's store id, or null when the item yields no layer
388404 */
389405async function addServiceToMap (
390406 item : EarthdataGisItem ,
391- onHint ?: ( hint : string ) => void ,
392- webMapId ?: string ,
393- fit = true ,
407+ options : AddServiceOptions = { } ,
394408) : Promise < string | null > {
409+ const { onHint, webMapId, fit = true , minVisibleZoom : presetMinVisibleZoom } = options ;
395410 const ownerMetadata = webMapId ? { earthdataWebMapId : webMapId } : undefined ;
396411
397412 if ( item . kind === "feature" ) {
@@ -414,7 +429,12 @@ async function addServiceToMap(
414429
415430 // Best-effort: a service that never answers must not block the add, so the
416431 // lookup is bounded and any failure simply leaves the layer unconstrained.
417- const minVisibleZoom = await withVisibilityTimeout ( fetchMinVisibleZoom ( item ) ) ;
432+ // `undefined` means "not looked up yet"; `null` is a completed lookup that
433+ // found no constraint, so only the former triggers a fetch here.
434+ const minVisibleZoom =
435+ presetMinVisibleZoom !== undefined
436+ ? presetMinVisibleZoom
437+ : await withVisibilityTimeout ( fetchMinVisibleZoom ( item ) ) ;
418438
419439 const layerId = useAppStore . getState ( ) . addTileLayer ( item . title , {
420440 type : layerTypeForTiles ( [ tileUrl ] ) ,
@@ -453,15 +473,25 @@ async function addWebMapToMap(
453473 const renderable = Array . isArray ( layers ) ? ( layers as WebMapLayer [ ] ) : [ ] ;
454474 if ( renderable . length === 0 ) throw new Error ( labels . webMapEmpty ) ;
455475
476+ const children = renderable . map ( ( layer , index ) => webMapLayerAsItem ( item , layer , index ) ) ;
477+ // Each raster child's visibility lookup is a bounded pair of requests, so
478+ // running them together keeps a web map of N unresponsive image layers from
479+ // costing N x VISIBILITY_LOOKUP_TIMEOUT_MS. Only the store writes below need
480+ // to stay ordered.
481+ const minVisibleZooms = await Promise . all (
482+ children . map ( ( child ) =>
483+ child . kind === "image" ? withVisibilityTimeout ( fetchMinVisibleZoom ( child ) ) : null ,
484+ ) ,
485+ ) ;
486+
456487 const addedIds : string [ ] = [ ] ;
457- for ( const [ index , layer ] of renderable . entries ( ) ) {
488+ for ( const [ index , child ] of children . entries ( ) ) {
458489 try {
459- const id = await addServiceToMap (
460- webMapLayerAsItem ( item , layer , index ) ,
461- undefined ,
462- item . id ,
463- false ,
464- ) ;
490+ const id = await addServiceToMap ( child , {
491+ webMapId : item . id ,
492+ fit : false ,
493+ minVisibleZoom : minVisibleZooms [ index ] ,
494+ } ) ;
465495 if ( id ) addedIds . push ( id ) ;
466496 } catch {
467497 // One unreachable layer must not abandon the rest of the web map; the
@@ -471,7 +501,9 @@ async function addWebMapToMap(
471501 if ( addedIds . length === 0 ) throw new Error ( labels . webMapEmpty ) ;
472502
473503 appRef ?. addLayerGroup ?.( item . title , addedIds ) ;
474- if ( item . bbox && revealRasterLayer ( item . bbox , null ) ) onHint ?.( labels . zoomedToData ) ;
504+ // A web map is a curated composition, so its own extent is the right view;
505+ // the per-layer zoom-to-data rule would over-zoom for its other layers.
506+ if ( item . bbox ) appRef ?. fitBounds ?.( item . bbox ) ;
475507 onHint ?.( labels . webMapAdded ( addedIds . length , renderable . length ) ) ;
476508}
477509
@@ -597,12 +629,19 @@ function openDetailsModal(item: EarthdataGisItem): void {
597629 closeDetailsDialog ?.( ) ;
598630
599631 const overlay = document . createElement ( "div" ) ;
632+ // Captured before the dialog steals focus so closing can hand it back to the
633+ // card button that opened it, instead of dumping the user at the page top.
634+ const previouslyFocused = document . activeElement as HTMLElement | null ;
600635 overlay . style . cssText =
601636 "position:fixed;inset:0;z-index:2147483000;display:flex;" +
602637 "align-items:center;justify-content:center;padding:16px;" +
603638 "background:rgba(0,0,0,0.5);" ;
604639
605640 const dialog = document . createElement ( "div" ) ;
641+ dialog . setAttribute ( "role" , "dialog" ) ;
642+ dialog . setAttribute ( "aria-modal" , "true" ) ;
643+ dialog . setAttribute ( "aria-label" , labels . detailsHeading ) ;
644+ dialog . tabIndex = - 1 ;
606645 dialog . style . cssText =
607646 "display:flex;flex-direction:column;width:100%;max-width:560px;" +
608647 "max-height:80vh;border-radius:8px;overflow:hidden;" +
@@ -679,6 +718,7 @@ function openDetailsModal(item: EarthdataGisItem): void {
679718 const close = ( ) : void => {
680719 overlay . remove ( ) ;
681720 document . removeEventListener ( "keydown" , onKey ) ;
721+ previouslyFocused ?. focus ?.( ) ;
682722 if ( closeDetailsDialog === close ) closeDetailsDialog = null ;
683723 } ;
684724 const onKey = ( event : KeyboardEvent ) : void => {
@@ -690,6 +730,9 @@ function openDetailsModal(item: EarthdataGisItem): void {
690730 closeButton . addEventListener ( "click" , close ) ;
691731 document . addEventListener ( "keydown" , onKey ) ;
692732 document . body . appendChild ( overlay ) ;
733+ // Move focus inside so the dialog is reachable by keyboard and announced;
734+ // without this, Tab keeps walking the panel behind the overlay.
735+ closeButton . focus ( ) ;
693736 closeDetailsDialog = close ;
694737}
695738
@@ -1083,6 +1126,14 @@ export const maplibreEarthdataGisPlugin: GeoLibrePlugin = {
10831126 app . closeRightPanel ?.( PANEL_ID ) ;
10841127 unregisterPanel ?.( ) ;
10851128 unregisterPanel = null ;
1129+ // Both panel APIs above are optional-chained, so the host may never invoke
1130+ // the render cleanup. Tear the panel down here too, or its store
1131+ // subscription outlives deactivation and a later setEarthdataGisLabels
1132+ // remounts into a detached container. Already-run cleanup leaves
1133+ // disposePanel null, so this is a no-op in the normal case.
1134+ disposePanel ?.( ) ;
1135+ disposePanel = null ;
1136+ panelContainer = null ;
10861137 closeDetailsDialog ?.( ) ;
10871138 pendingAdds . clear ( ) ;
10881139 appRef = null ;
0 commit comments