Skip to content

Commit 69f0dde

Browse files
committed
Address CodeRabbit review feedback
- Let Cancel, Escape, and the backdrop abort an in-flight COG export. The retry ladder could otherwise hold the dialog for the better part of two minutes with no way out. An AbortSignal is threaded through downloadCog and fetchExport, where it is merged with the per-attempt deadline, and a cancel is no longer reported as a failure. - Stop the retry ladder on the longer side rather than on both sides. The previous guard was per-axis-independent, so a 2048x512 request kept halving while only its width cleared the floor. Gating on the shorter side instead would cut the ladder short for an ordinary 16:9 view, which is when the retry is most useful, so the decision now lives in a tested nextExportSize helper. - Report the size actually delivered on success, since a retry can step it below what the dialog previewed.
1 parent 7ffe484 commit 69f0dde

5 files changed

Lines changed: 120 additions & 25 deletions

File tree

apps/geolibre-desktop/src/components/layout/TopToolbar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,8 @@ export function TopToolbar({
319319
cogDownload: t("earthdataGis.cogDownload"),
320320
cogDownloading: t("earthdataGis.cogDownloading"),
321321
cogRetrying: (width, height) => t("earthdataGis.cogRetrying", { width, height }),
322-
cogDone: t("earthdataGis.cogDone"),
322+
cogConverting: t("earthdataGis.cogConverting"),
323+
cogDone: (width, height) => t("earthdataGis.cogDone", { width, height }),
323324
cogFailed: (message) => t("earthdataGis.cogFailed", { message }),
324325
cogUnavailable: t("earthdataGis.cogUnavailable"),
325326
cancel: t("earthdataGis.cancel"),

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2743,7 +2743,8 @@
27432743
"cogDownload": "Download",
27442744
"cogDownloading": "Exporting and converting…",
27452745
"cogRetrying": "The service refused that size; retrying at {{width}} x {{height}} px…",
2746-
"cogDone": "Saved the COG.",
2746+
"cogConverting": "Converting to a COG…",
2747+
"cogDone": "Saved the COG at {{width}} x {{height}} px.",
27472748
"cogFailed": "Could not download the COG: {{message}}",
27482749
"cogUnavailable": "Downloading requires the desktop app or a browser save dialog.",
27492750
"cancel": "Cancel",

packages/plugins/src/plugins/earthdata-gis-api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,29 @@ export function exportImageSize(
376376
};
377377
}
378378

379+
/**
380+
* Halves an export size for the next retry, or reports that the ladder is
381+
* exhausted.
382+
*
383+
* The floor is compared against the **longer** side. Testing both sides made it
384+
* per-axis-independent — a 2048x512 request would keep halving as long as the
385+
* width alone cleared the floor — while testing the shorter side would stop the
386+
* ladder after one step for an ordinary 16:9 view, which is exactly the case
387+
* the retry exists to rescue.
388+
*
389+
* @param size - The size that just failed
390+
* @param minPixels - Smallest longer-side export worth attempting
391+
* @returns The next size to try, or null when nothing smaller is worth asking for
392+
*/
393+
export function nextExportSize(
394+
size: { width: number; height: number },
395+
minPixels: number,
396+
): { width: number; height: number } | null {
397+
const next = { width: Math.round(size.width / 2), height: Math.round(size.height / 2) };
398+
if (Math.max(next.width, next.height) < minPixels) return null;
399+
return next;
400+
}
401+
379402
/**
380403
* Builds a concrete (non-templated) export URL for one area, used for the
381404
* GeoTIFF download rather than for map tiles.

packages/plugins/src/plugins/maplibre-earthdata-gis.ts

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
804831
async 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

tests/earthdata-gis-api.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
exportFileName,
2020
exportImageSize,
2121
fetchExportLimits,
22+
nextExportSize,
2223
parseMaxPixelSize,
2324
parseSearchResponse,
2425
parseWebMapLayers,
@@ -559,6 +560,35 @@ describe("earthdata gis api", () => {
559560
});
560561
});
561562

563+
describe("nextExportSize", () => {
564+
it("halves a square export until the floor", () => {
565+
assert.deepEqual(nextExportSize({ width: 2048, height: 2048 }, 512), {
566+
width: 1024,
567+
height: 1024,
568+
});
569+
assert.equal(nextExportSize({ width: 512, height: 512 }, 512), null);
570+
});
571+
572+
it("keeps stepping a 16:9 view down, where the retry is most useful", () => {
573+
// Gating on the shorter side would stop after one step here.
574+
assert.deepEqual(nextExportSize({ width: 2048, height: 1152 }, 512), {
575+
width: 1024,
576+
height: 576,
577+
});
578+
assert.deepEqual(nextExportSize({ width: 1024, height: 576 }, 512), {
579+
width: 512,
580+
height: 288,
581+
});
582+
});
583+
584+
it("stops once the longer side falls under the floor", () => {
585+
// The previous guard tested both axes, so a thin export kept halving as
586+
// long as its width alone cleared the floor.
587+
assert.equal(nextExportSize({ width: 512, height: 128 }, 512), null);
588+
assert.equal(nextExportSize({ width: 900, height: 100 }, 512), null);
589+
});
590+
});
591+
562592
describe("buildExportDownloadUrl", () => {
563593
it("requests a concrete bbox and a TIFF, not a tile template", () => {
564594
const url = new URL(

0 commit comments

Comments
 (0)