@@ -32,6 +32,7 @@ import {
3232 fetchMinVisibleZoom ,
3333 fetchWebMapLayers ,
3434 HTTP_URL_RE ,
35+ nextExportSize ,
3536 searchEarthdataGis ,
3637 type WebMapLayer ,
3738 webMapLayerAsItem ,
@@ -135,7 +136,8 @@ export interface EarthdataGisLabels {
135136 cogDownload : string ;
136137 cogDownloading : string ;
137138 cogRetrying : ( width : number , height : number ) => string ;
138- cogDone : string ;
139+ cogConverting : string ;
140+ cogDone : ( width : number , height : number ) => string ;
139141 cogFailed : ( message : string ) => string ;
140142 cogUnavailable : string ;
141143 cancel : string ;
@@ -212,7 +214,8 @@ export const DEFAULT_EARTHDATA_GIS_LABELS: EarthdataGisLabels = {
212214 cogDownloading : "Exporting and converting…" ,
213215 cogRetrying : ( width , height ) =>
214216 `The service refused that size; retrying at ${ width } x ${ height } px…` ,
215- cogDone : "Saved the COG." ,
217+ cogConverting : "Converting to a COG…" ,
218+ cogDone : ( width , height ) => `Saved the COG at ${ width } x ${ height } px.` ,
216219 cogFailed : ( message ) => `Could not download the COG: ${ message } ` ,
217220 cogUnavailable : "Downloading requires the desktop app or a browser save dialog." ,
218221 cancel : "Cancel" ,
@@ -696,6 +699,10 @@ function openCogModal(item: EarthdataGisItem): void {
696699 let limits : ExportLimits = { maxWidth : 4096 , maxHeight : 4096 } ;
697700 let serviceExtent3857 : [ number , number , number , number ] | null = null ;
698701 let running = false ;
702+ // Set while an export is in flight so Cancel / Escape / the backdrop can stop
703+ // it: the retry ladder can otherwise hold the dialog for the better part of
704+ // two minutes with no way out.
705+ let inflight : AbortController | null = null ;
699706
700707 const refresh = ( ) : void => {
701708 // A service with no published extent can only be exported over the map view.
@@ -727,20 +734,20 @@ function openCogModal(item: EarthdataGisItem): void {
727734 }
728735
729736 const close = ( ) : void => {
737+ inflight ?. abort ( ) ;
738+ inflight = null ;
730739 overlay . remove ( ) ;
731740 document . removeEventListener ( "keydown" , onKey ) ;
732741 previouslyFocused ?. focus ?.( ) ;
733742 if ( closeDetailsDialog === close ) closeDetailsDialog = null ;
734743 } ;
735744 const onKey = ( event : KeyboardEvent ) : void => {
736- if ( event . key === "Escape" && ! running ) close ( ) ;
745+ if ( event . key === "Escape" ) close ( ) ;
737746 } ;
738747 overlay . addEventListener ( "click" , ( event ) => {
739- if ( event . target === overlay && ! running ) close ( ) ;
740- } ) ;
741- cancelButton . addEventListener ( "click" , ( ) => {
742- if ( ! running ) close ( ) ;
748+ if ( event . target === overlay ) close ( ) ;
743749 } ) ;
750+ cancelButton . addEventListener ( "click" , close ) ;
744751
745752 downloadButton . addEventListener ( "click" , ( ) => {
746753 if ( running ) return ;
@@ -755,12 +762,23 @@ function openCogModal(item: EarthdataGisItem): void {
755762 downloadButton . disabled = true ;
756763 status . style . color = "hsl(var(--muted-foreground))" ;
757764 status . textContent = labels . cogDownloading ;
758- void downloadCog ( item , bounds , limits , ( message ) => {
759- status . textContent = message ;
760- } )
761- . then ( ( saved ) => {
762- if ( saved ) {
763- status . textContent = labels . cogDone ;
765+ const controller = new AbortController ( ) ;
766+ inflight = controller ;
767+ void downloadCog (
768+ item ,
769+ bounds ,
770+ limits ,
771+ ( message ) => {
772+ status . textContent = message ;
773+ } ,
774+ controller . signal ,
775+ )
776+ . then ( ( result ) => {
777+ if ( controller . signal . aborted ) return ;
778+ if ( result . saved && result . size ) {
779+ // Report the size actually delivered: the retry ladder may have
780+ // stepped it below what the dialog previewed.
781+ status . textContent = labels . cogDone ( result . size . width , result . size . height ) ;
764782 close ( ) ;
765783 } else {
766784 // The user dismissed the save dialog; leave the modal open so the
@@ -769,14 +787,16 @@ function openCogModal(item: EarthdataGisItem): void {
769787 }
770788 } )
771789 . catch ( ( error : unknown ) => {
790+ if ( controller . signal . aborted ) return ;
772791 status . style . color = "hsl(var(--destructive))" ;
773792 status . textContent = labels . cogFailed (
774793 error instanceof Error ? error . message : String ( error ) ,
775794 ) ;
776795 } )
777796 . finally ( ( ) => {
778797 running = false ;
779- refresh ( ) ;
798+ if ( inflight === controller ) inflight = null ;
799+ if ( overlay . isConnected ) refresh ( ) ;
780800 } ) ;
781801 } ) ;
782802
@@ -794,20 +814,28 @@ function openCogModal(item: EarthdataGisItem): void {
794814 } ) ;
795815}
796816
817+ /** What a COG download produced: whether a file was written, and at what size. */
818+ interface CogDownloadResult {
819+ saved : boolean ;
820+ /** The size actually exported, which the retry ladder may have reduced. */
821+ size : { width : number ; height : number } | null ;
822+ }
823+
797824/**
798825 * Exports the area as a GeoTIFF and hands it to the host to be re-encoded as a
799826 * COG and saved.
800827 *
801- * @returns True when a file was written, false when the save was cancelled
828+ * @returns Whether a file was written, and the size it was exported at
802829 * @throws When the export request fails or returns something other than a TIFF
803830 */
804831async function downloadCog (
805832 item : EarthdataGisItem ,
806833 bbox3857 : [ number , number , number , number ] ,
807834 limits : ExportLimits ,
808835 onProgress ?: ( message : string ) => void ,
809- ) : Promise < boolean > {
810- if ( ! cogSaver ) return false ;
836+ signal ?: AbortSignal ,
837+ ) : Promise < CogDownloadResult > {
838+ if ( ! cogSaver ) return { saved : false , size : null } ;
811839 let size = exportImageSize ( bbox3857 , cappedLimits ( limits ) ) ;
812840 let lastError : Error | null = null ;
813841
@@ -816,16 +844,21 @@ async function downloadCog(
816844 // at 4096px and answers 503 at 4977px. Step the request down on failure so a
817845 // service that cannot manage the first size still yields a file.
818846 for ( let attempt = 0 ; attempt < EXPORT_ATTEMPTS ; attempt += 1 ) {
847+ if ( signal ?. aborted ) return { saved : false , size : null } ;
819848 const url = buildExportDownloadUrl ( item , bbox3857 , size , "tiff" ) ;
820- if ( ! url ) return false ;
849+ if ( ! url ) return { saved : false , size : null } ;
821850 if ( attempt > 0 ) onProgress ?.( labels . cogRetrying ( size . width , size . height ) ) ;
822851 try {
823- const bytes = await fetchExport ( url ) ;
824- return cogSaver ( bytes , exportFileName ( item . title , "tif" ) ) ;
852+ const bytes = await fetchExport ( url , signal ) ;
853+ onProgress ?.( labels . cogConverting ) ;
854+ const saved = await cogSaver ( bytes , exportFileName ( item . title , "tif" ) ) ;
855+ return { saved, size } ;
825856 } catch ( error ) {
857+ // A cancel is the user's decision, not a failure to retry or report.
858+ if ( signal ?. aborted ) return { saved : false , size : null } ;
826859 lastError = error instanceof Error ? error : new Error ( String ( error ) ) ;
827- const next = { width : Math . round ( size . width / 2 ) , height : Math . round ( size . height / 2 ) } ;
828- if ( next . width < EXPORT_MIN_PIXELS && next . height < EXPORT_MIN_PIXELS ) break ;
860+ const next = nextExportSize ( size , EXPORT_MIN_PIXELS ) ;
861+ if ( ! next ) break ;
829862 size = next ;
830863 }
831864 }
@@ -840,19 +873,26 @@ async function downloadCog(
840873 * @throws With the service's own message when it reports an error, or a
841874 * timeout/status message otherwise
842875 */
843- async function fetchExport ( url : string ) : Promise < Uint8Array > {
876+ async function fetchExport ( url : string , signal ?: AbortSignal ) : Promise < Uint8Array > {
877+ // One controller fed by both the per-attempt deadline and the caller's cancel,
878+ // so either can stop the request. (AbortSignal.any would do this, but it is
879+ // newer than the browsers this app still targets.)
844880 const controller = new AbortController ( ) ;
845881 const timer = setTimeout ( ( ) => controller . abort ( ) , EXPORT_ATTEMPT_TIMEOUT_MS ) ;
882+ const onCancel = ( ) : void => controller . abort ( ) ;
883+ signal ?. addEventListener ( "abort" , onCancel ) ;
846884 let response : Response ;
847885 try {
848886 response = await fetch ( url , { signal : controller . signal } ) ;
849887 } catch ( error ) {
850888 if ( error instanceof DOMException && error . name === "AbortError" ) {
889+ if ( signal ?. aborted ) throw error ;
851890 throw new Error ( "the service did not respond in time at this size" ) ;
852891 }
853892 throw error ;
854893 } finally {
855894 clearTimeout ( timer ) ;
895+ signal ?. removeEventListener ( "abort" , onCancel ) ;
856896 }
857897 if ( ! response . ok ) throw new Error ( `the service returned ${ response . status } at this size` ) ;
858898
0 commit comments