diff --git a/eslint.config.mjs b/eslint.config.mjs index 0c6ac96..c5c774a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,9 +13,11 @@ export default [ document: 'readonly', console: 'readonly', fetch: 'readonly', + AbortController: 'readonly', URL: 'readonly', URLSearchParams: 'readonly', DOMParser: 'readonly', + HTMLDialogElement: 'readonly', Event: 'readonly', alert: 'readonly', setTimeout: 'readonly', @@ -42,4 +44,4 @@ export default [ 'no-prototype-builtins': 'off' } } -]; \ No newline at end of file +]; diff --git a/viewer/js/app.js b/viewer/js/app.js index 6947a6c..1313b0b 100644 --- a/viewer/js/app.js +++ b/viewer/js/app.js @@ -1,6 +1,5 @@ import { TIME_EXPAND_LIMIT, - NCSS_WARN_TIMESTEPS, DEFAULT_VARIABLE_LABELS, CRS_OPTIONS, DEFAULT_CANADA_BBOX_4326, @@ -326,8 +325,7 @@ const subsettingController = createSubsettingController({ subsetDrawSource, subsetDrawLayer, getCurrentCrs, - }, - NCSS_WARN_TIMESTEPS, + } }); ({ diff --git a/viewer/js/portal/datasets.js b/viewer/js/portal/datasets.js index 41d82b7..5ced8fc 100644 --- a/viewer/js/portal/datasets.js +++ b/viewer/js/portal/datasets.js @@ -1,11 +1,11 @@ import { WMS_VERSION, FALLBACK_PALETTES, - DEFAULT_CANADA_BBOX_4326 -} from '../core/config.js'; + DEFAULT_CANADA_BBOX_4326, +} from "../core/config.js"; export function variableLabelForGroup(varCode, group, defaultLabels = {}) { - const v = String(varCode || ''); + const v = String(varCode || ""); const fromGroup = group?.variable?.labels?.[v]; return fromGroup || defaultLabels[v] || v; } @@ -20,19 +20,38 @@ export function createDatasetController({ time, map: mapDeps, layer, - render + render, }) { - const { legendPanel, crsSelect, subsetTimeStart, subsetTimeEnd, timeSlider } = ui; + const { legendPanel, crsSelect, subsetTimeStart, subsetTimeEnd, timeSlider } = + ui; const { setStatus, stopStatusSpinner, cancelPendingSubsetStatus } = status; const { fetchText } = services; - const { parseWmsCapabilities, fetchLayerDetails, deriveTimesFromLayerDetails, fetchLayerTimesteps, updateTimeUI, toDateInputValue } = time; - const { getCurrentCrs, setMapProjection, pickBestCrsForLayer, fitMapToBbox4326 } = mapDeps; - const { deriveScaleRangeFromMetadata, applyLayerScaleDefaults, syncPaletteEnabled, populatePaletteSelect, pickDefaultPaletteForVar } = layer; + const { + parseWmsCapabilities, + fetchLayerDetails, + deriveTimesFromLayerDetails, + fetchLayerTimesteps, + updateTimeUI, + toDateInputValue, + } = time; + const { + getCurrentCrs, + setMapProjection, + pickBestCrsForLayer, + fitMapToBbox4326, + } = mapDeps; + const { + deriveScaleRangeFromMetadata, + applyLayerScaleDefaults, + syncPaletteEnabled, + populatePaletteSelect, + pickDefaultPaletteForVar, + } = layer; const { refreshInfoPanel, updateMap } = render; function threddsRoot() { - const root = String(portal.threddsRoot || '/thredds/'); - return root.endsWith('/') ? root : `${root}/`; + const root = String(portal.threddsRoot || "/thredds/"); + return root.endsWith("/") ? root : `${root}/`; } function fileServerUrlForUrlPath(urlPath) { @@ -48,25 +67,39 @@ export function createDatasetController({ } function ncpartitionerBase() { - return ' /pdp-next/ncpartitioner/partition/'; + return "/pdp-next/ncpartitioner/"; } function normalizePortalTimeValue(value) { - const raw = String(value || '').trim(); - if (!raw) return ''; - const isoLike = raw.includes('T') ? raw : raw.replace(' ', 'T'); - const withZone = /(?:Z|[+-]\d{2}:\d{2})$/.test(isoLike) ? isoLike : `${isoLike}Z`; + const raw = String(value || "").trim(); + if (!raw) return ""; + const isoLike = raw.includes("T") ? raw : raw.replace(" ", "T"); + const withZone = /(?:Z|[+-]\d{2}:\d{2})$/.test(isoLike) + ? isoLike + : `${isoLike}Z`; const dt = new Date(withZone); return Number.isNaN(dt.getTime()) ? raw : dt.toISOString(); } async function resolveLayersFromCapabilities() { - const capsUrl = `${state.currentDataset.wmsBase}?service=WMS&request=GetCapabilities&version=${encodeURIComponent(WMS_VERSION)}`; + const capsUrl = `${ + state.currentDataset.wmsBase + }?service=WMS&request=GetCapabilities&version=${encodeURIComponent( + WMS_VERSION, + )}`; const capsText = await fetchText(capsUrl); const layers = parseWmsCapabilities(capsText); - if (!layers.length) throw new Error('No layers found in GetCapabilities'); + if (!layers.length) throw new Error("No layers found in GetCapabilities"); const preferred = state.variable - ? layers.find((l) => String(l.name || '').trim().toLowerCase() === String(state.variable || '').trim().toLowerCase()) + ? layers.find( + (l) => + String(l.name || "") + .trim() + .toLowerCase() === + String(state.variable || "") + .trim() + .toLowerCase(), + ) : null; state.selectedLayer = preferred || layers[0]; state.layers = layers; @@ -97,12 +130,18 @@ export function createDatasetController({ } function initTimesFromLayer(timeMetadata) { - const timeStart = state.selectedLayer.time?.start || ''; - const timeEnd = state.selectedLayer.time?.end || ''; - subsetTimeStart.value = timeStart ? toDateInputValue(timeStart) : ''; - subsetTimeEnd.value = timeEnd ? toDateInputValue(timeEnd) : ''; - state.times = Array.isArray(state.selectedLayer.time?.times) ? state.selectedLayer.time.times : []; - if (!state.times.length && Number(timeMetadata?.count || 0) === 1 && timeMetadata?.start) { + const timeStart = state.selectedLayer.time?.start || ""; + const timeEnd = state.selectedLayer.time?.end || ""; + subsetTimeStart.value = timeStart ? toDateInputValue(timeStart) : ""; + subsetTimeEnd.value = timeEnd ? toDateInputValue(timeEnd) : ""; + state.times = Array.isArray(state.selectedLayer.time?.times) + ? state.selectedLayer.time.times + : []; + if ( + !state.times.length && + Number(timeMetadata?.count || 0) === 1 && + timeMetadata?.start + ) { const singleTime = normalizePortalTimeValue(timeMetadata.start); if (singleTime) { state.times = [singleTime]; @@ -124,7 +163,10 @@ export function createDatasetController({ async function expandTimesFromMetadata() { if (state.times.length > 1) return; - const metadataTimes = await fetchLayerTimesteps(state.currentDataset.wmsBase, state.selectedLayer.name); + const metadataTimes = await fetchLayerTimesteps( + state.currentDataset.wmsBase, + state.selectedLayer.name, + ); if (metadataTimes.times.length > state.times.length) { state.times = metadataTimes.times; setSubsetTimeInputs(metadataTimes.start, metadataTimes.end); @@ -139,29 +181,52 @@ export function createDatasetController({ state.times = [singleTime]; subsetTimeStart.value = toDateInputValue(singleTime); subsetTimeEnd.value = toDateInputValue(singleTime); - timeSlider.value = '0'; + timeSlider.value = "0"; updateTimeUI(); } function applyPaletteAndScale(details, rendering) { - const paletteCandidates = details?.palettes?.length ? details.palettes : [...FALLBACK_PALETTES]; - const paletteDefault = pickDefaultPaletteForVar(state.variable, paletteCandidates, details?.defaultPalette || FALLBACK_PALETTES[0]); + const paletteCandidates = details?.palettes?.length + ? details.palettes + : [...FALLBACK_PALETTES]; + const paletteDefault = pickDefaultPaletteForVar( + state.variable, + paletteCandidates, + details?.defaultPalette || FALLBACK_PALETTES[0], + ); populatePaletteSelect(paletteCandidates, paletteDefault); const detailsRange = deriveScaleRangeFromMetadata(details); - const fileRange = (rendering && Number.isFinite(rendering.min) && Number.isFinite(rendering.max)) - ? { min: Number(rendering.min), max: Number(rendering.max) } - : null; + const fileRange = + rendering && + Number.isFinite(rendering.min) && + Number.isFinite(rendering.max) + ? { min: Number(rendering.min), max: Number(rendering.max) } + : null; applyLayerScaleDefaults(fileRange || detailsRange); syncPaletteEnabled(); } - async function loadDatasetFromUrlPath({ name, urlPath, variable, metadata = null, rendering = null, timeMetadata = null }) { + async function loadDatasetFromUrlPath({ + name, + urlPath, + variable, + metadata = null, + rendering = null, + timeMetadata = null, + }) { try { cancelPendingSubsetStatus?.(); stopStatusSpinner(); - setStatus('Loading dataset…'); - legendPanel?.classList.add('hidden'); - state.currentDataset = { name, urlPath, wmsBase: wmsBaseForUrlPath(urlPath), metadata, rendering, timeMetadata }; + setStatus("Loading dataset…"); + legendPanel?.classList.add("hidden"); + state.currentDataset = { + name, + urlPath, + wmsBase: wmsBaseForUrlPath(urlPath), + metadata, + rendering, + timeMetadata, + }; state.variable = variable || null; state.layers = []; state.selectedLayer = null; @@ -175,9 +240,12 @@ export function createDatasetController({ let details = null; try { - details = await fetchLayerDetails(state.currentDataset.wmsBase, state.selectedLayer.name); + details = await fetchLayerDetails( + state.currentDataset.wmsBase, + state.selectedLayer.name, + ); } catch (err) { - console.warn('layerDetails unavailable:', err?.message || err); + console.warn("layerDetails unavailable:", err?.message || err); } state.layerDetails = details; @@ -188,7 +256,7 @@ export function createDatasetController({ applyPaletteAndScale(details, rendering); refreshInfoPanel(); updateMap(); - setStatus('Ready'); + setStatus("Ready"); } catch (err) { console.error(err); setStatus(`Error: ${err.message}`, true); @@ -201,6 +269,6 @@ export function createDatasetController({ wmsBaseForUrlPath, dodsBaseForUrlPath, ncpartitionerBase, - loadDatasetFromUrlPath + loadDatasetFromUrlPath, }; } diff --git a/viewer/js/portal/menu.js b/viewer/js/portal/menu.js index 9a68fcd..045b028 100644 --- a/viewer/js/portal/menu.js +++ b/viewer/js/portal/menu.js @@ -183,7 +183,7 @@ export function createMenuController({ setStatus('Loading portal metadata…'); const metaPayload = await loadPortalMeta(portal.id); renderMenuFromPortalMeta(metaPayload); - setStatus('Ready - Select a dataset'); + setStatus('Ready') } function populatePortalSelect() { diff --git a/viewer/js/subsetting.js b/viewer/js/subsetting.js index bbdd207..e3df8d5 100644 --- a/viewer/js/subsetting.js +++ b/viewer/js/subsetting.js @@ -10,8 +10,7 @@ export function createSubsettingController({ status, services, time, - mapDeps, - NCSS_WARN_TIMESTEPS + mapDeps }) { const logger = createSubsetLogger(); const drawController = createSubsetDrawController({ @@ -30,7 +29,6 @@ export function createSubsettingController({ status, services, time, - NCSS_WARN_TIMESTEPS, logger, drawController, indexController diff --git a/viewer/js/subsetting/download.js b/viewer/js/subsetting/download.js index 3f7d0eb..c34dfae 100644 --- a/viewer/js/subsetting/download.js +++ b/viewer/js/subsetting/download.js @@ -5,7 +5,6 @@ export function createSubsetDownloadController({ status, services, time, - NCSS_WARN_TIMESTEPS, logger, drawController, indexController @@ -19,16 +18,11 @@ export function createSubsetDownloadController({ const { startStatusSpinner, stopStatusSpinner, - setStatus, - suppressStatusUpdates, - unsuppressStatusUpdates, - forceSetStatus + setStatus } = status; const { - fetchText, fileServerUrlForUrlPath, - ncpartitionerBase, - threddsRoot + ncpartitionerBase } = services; const { getSubsetTimeMode, @@ -37,10 +31,26 @@ export function createSubsetDownloadController({ } = time; const BACKGROUND_STATUS_TIMEOUT_MS = 120000; - const BACKGROUND_STATUS_POLL_MS = 1000; + const BACKGROUND_STATUS_POLL_MS = 1500; + const BACKGROUND_STATUS_SLOW_POLL_MS = 10000; + const FULL_TIME_SUGGESTION_THRESHOLD = 0.6; + const SUBSET_WAITING_STATUS = 'Subset submitted. Waiting for server...'; + const SUBSET_LONG_WAIT_STATUS = 'Subset processing. Large requests may take several minutes. Waiting for server...'; const SUBSET_DOWNLOAD_LABEL = 'Download subset'; + const ROUTE = { + FULL_FILE: 'httpserver-full-file', + NCPARTITIONER: 'ncpartitioner' + }; + + // Thrown to short-circuit downloadSubset() + class SubsetCancelled extends Error {} + let activeBackgroundStatus = null; + let activeNcPollRunId = null; + let activeSubsetRunId = null; + let activeFetchController = null; + const ncpartitionerPublicRoot = new URL(ncpartitionerBase(), window.location.origin); function setSubsetDownloadBusy(isBusy) { subsetDownloadBtn.disabled = isBusy; @@ -51,55 +61,114 @@ export function createSubsetDownloadController({ return new Promise((resolve) => setTimeout(resolve, ms)); } + function replaceActiveFetchController() { + activeFetchController?.abort(); + activeFetchController = new AbortController(); + return activeFetchController; + } + + function clearActiveFetchController(controller = activeFetchController) { + if (controller && activeFetchController === controller) activeFetchController = null; + } + function clearBackgroundSubsetStatus(message = '', isError = false) { - if (activeBackgroundStatus?.timer) { - clearInterval(activeBackgroundStatus.timer); - } activeBackgroundStatus = null; setSubsetDownloadBusy(false); if (message) setStatus(message, isError); } function startBackgroundSubsetStatus(runId) { - if (activeBackgroundStatus?.timer) { - clearInterval(activeBackgroundStatus.timer); - } activeBackgroundStatus = null; setSubsetDownloadBusy(true); - setStatus('Subset submitted. Waiting for server... (0s)'); - suppressStatusUpdates(); - const startedAt = Date.now(); - const timer = setInterval(() => { - if (!activeBackgroundStatus || activeBackgroundStatus.runId !== runId) { - clearInterval(timer); - return; - } - const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000); - if ((Date.now() - startedAt) >= BACKGROUND_STATUS_TIMEOUT_MS) { - clearInterval(timer); - activeBackgroundStatus = null; - unsuppressStatusUpdates(); - setSubsetDownloadBusy(false); - setStatus('Subset is still processing in the background. Check browser downloads; server confirmation may lag.'); - return; - } - forceSetStatus(`Subset submitted. Waiting for server... (${elapsedSeconds}s)`); - }, BACKGROUND_STATUS_POLL_MS); - activeBackgroundStatus = { runId, timer }; - // Return a stop function the async artifact-watcher can always call, - // regardless of whether activeBackgroundStatus has been replaced by a later run. + activeBackgroundStatus = { runId }; return () => { - clearInterval(timer); if (activeBackgroundStatus?.runId === runId) activeBackgroundStatus = null; - unsuppressStatusUpdates(); setSubsetDownloadBusy(false); }; } - function cancelPendingSubsetStatus() { - unsuppressStatusUpdates(); + function queuePositionFor(payload) { + const value = payload?.queue_position ?? payload?.queuePosition ?? null; + return Number.isInteger(value) && value > 0 ? value : null; + } + + function chunkProgressFor(payload) { + const complete = payload?.chunks_complete ?? payload?.chunksComplete ?? null; + const total = payload?.chunks_total ?? payload?.chunksTotal ?? null; + if (!Number.isInteger(complete) || !Number.isInteger(total) || total <= 0) return null; + return { complete, total }; + } + + async function promptLargeSubsetChoice(requestedFraction) { + const message = `This request covers ${Math.round(requestedFraction * 100)}% of the available timesteps. ` + + 'Downloading the full time range for this spatial subset is usually faster for you and better for other users.'; + const dialog = document.getElementById('largeSubsetDialog'); + const messageEl = document.getElementById('largeSubsetDialogMessage'); + + if (!(dialog instanceof HTMLDialogElement) || !messageEl) { + const continueSubset = window.confirm( + `${message}\n\nPress OK to continue with subset generation.\nPress Cancel to switch to the full time range instead.` + ); + return continueSubset ? 'continue' : 'full'; + } + + messageEl.textContent = message; + dialog.returnValue = 'cancel'; + dialog.showModal(); + + const choice = await new Promise((resolve) => { + const onClose = () => { + cleanup(); + resolve(dialog.returnValue || 'cancel'); + }; + const onCancel = (event) => { + event.preventDefault(); + dialog.close('cancel'); + }; + const onClick = (event) => { + if (event.target === dialog) dialog.close('cancel'); + }; + const cleanup = () => { + dialog.removeEventListener('close', onClose); + dialog.removeEventListener('cancel', onCancel); + dialog.removeEventListener('click', onClick); + }; + dialog.addEventListener('close', onClose); + dialog.addEventListener('cancel', onCancel); + dialog.addEventListener('click', onClick); + }); + + return choice === 'full' ? 'full' : (choice === 'continue' ? 'continue' : 'cancel'); + } + + function waitingStatusMessage(queuePosition, isLongWait = false) { + const baseMessage = isLongWait ? SUBSET_LONG_WAIT_STATUS : SUBSET_WAITING_STATUS; + return queuePosition !== null + ? `${baseMessage} Queue position: ${queuePosition}.` + : baseMessage; + } + + function runningStatusMessage(payload, isLongWait = false) { + const phase = String(payload?.phase || '').toLowerCase(); + const chunkProgress = chunkProgressFor(payload); + + if (phase === 'extracting' && chunkProgress) { + return `Subset processing. Extracted ${chunkProgress.complete}/${chunkProgress.total} chunks.`; + } + if (phase === 'merging' && chunkProgress) { + return `Subset processing. Merging ${chunkProgress.total} extracted chunks.`; + } + + return waitingStatusMessage(queuePositionFor(payload), isLongWait); + } + + function cancelPendingSubsetStatus(message = '', isError = false) { + activeNcPollRunId = null; + activeSubsetRunId = null; + activeFetchController?.abort(); + activeFetchController = null; clearBackgroundSubsetStatus(); - stopStatusSpinner(); + stopStatusSpinner(message, isError); } function triggerBackgroundDownload(url) { @@ -120,149 +189,256 @@ export function createSubsetDownloadController({ && outer.north >= (inner.north - tolerance); } - async function findOutputArtifactByBasename(basename, startUnixSec) { - const catalogUrl = `${threddsRoot()}catalog/output/catalog.xml?_ts=${Date.now()}`; - const xmlText = await fetchText(catalogUrl); - const parser = new DOMParser(); - const doc = parser.parseFromString(xmlText, 'application/xml'); - if (doc.querySelector('parsererror')) return null; - let best = null; - doc.querySelectorAll('dataset[urlPath]').forEach((ds) => { - const name = String(ds.getAttribute('name') || ''); - const urlPath = String(ds.getAttribute('urlPath') || ''); - if (!name.startsWith(`${basename}_`)) return; - const match = name.match(/_(\d+)\.(nc|nc4)$/i); - if (!match) return; - const ts = Number(match[1]); - if (!Number.isFinite(ts) || ts < (startUnixSec - 2)) return; - if (!best || ts > best.ts) best = { ts, name, urlPath }; - }); - return best; - } + /** + * Resolves the spatial extent for the subset based on the UI's spatial mode. + * Returns { bbox, useWholeSpatialDomain }. + * Throws SubsetCancelled if the user needs to take an action first (and has + * already been alerted) or if no extent could be determined. + */ + function resolveSpatialExtent(spatialMode, run) { + const datasetBbox = state.selectedLayer?.bbox4326 || { west: -180, south: -90, east: 180, north: 90 }; - async function waitForOutputArtifact({ basename, startUnixSec, timeoutMs = 3600000, pollMs = 2000 }) { - const started = Date.now(); - while ((Date.now() - started) < timeoutMs) { - try { - const hit = await findOutputArtifactByBasename(basename, startUnixSec); - if (hit) return hit; - } catch { /* artifact not yet in catalog — keep polling */ } - await sleep(pollMs); + if (spatialMode === 'whole') { + return { bbox: datasetBbox, useWholeSpatialDomain: true }; } - throw new Error('Timed out waiting for output artifact'); - } - async function downloadSubset() { - if (subsetDownloadBtn.disabled) return; - cancelPendingSubsetStatus(); - if (!state.currentDataset) return alert('Please select a dataset first'); - if (!state.variable) return alert('Could not infer variable for this file.'); - setSubsetDownloadBusy(true); - const run = logger.startSubsetRun('subset-download', { portal: portal.id, dataset: state.currentDataset?.urlPath || null }); - const spatialMode = (subsetSpatialMode?.value || 'viewport').toLowerCase(); - const datasetBbox = state.selectedLayer?.bbox4326 || { west: -180, south: -90, east: 180, north: 90 }; - let bbox = null; - let useWholeSpatialDomain = spatialMode === 'whole'; - if (spatialMode === 'whole') { - bbox = datasetBbox; - } else if (spatialMode === 'draw_bbox' || spatialMode === 'draw_point') { - bbox = drawController.getDrawnBbox4326(); + if (spatialMode === 'draw_bbox' || spatialMode === 'draw_point') { + const bbox = drawController.getDrawnBbox4326(); if (!bbox) { - setSubsetDownloadBusy(false); alert(spatialMode === 'draw_point' ? 'Please add a point on the map first.' : 'Please draw a geometry on the map first.'); logger.finishSubsetRun(run, 'cancelled', { reason: 'missing-drawn-bbox' }); - return; + throw new SubsetCancelled(); } - } else { - bbox = drawController.getCurrentViewBbox4326(); - useWholeSpatialDomain = bboxContains(bbox, datasetBbox); - if (useWholeSpatialDomain) bbox = datasetBbox; + return { bbox, useWholeSpatialDomain: false }; } + + const bbox = drawController.getCurrentViewBbox4326(); if (!bbox) { - setSubsetDownloadBusy(false); alert('Could not determine map extent for bbox.'); logger.finishSubsetRun(run, 'cancelled', { reason: 'missing-bbox' }); - return; + throw new SubsetCancelled(); } + const useWholeSpatialDomain = bboxContains(bbox, datasetBbox); + return { bbox: useWholeSpatialDomain ? datasetBbox : bbox, useWholeSpatialDomain }; + } + /** + * Resolves the requested time range based on the UI's time mode. + * Returns { timeMode, rangeStart, rangeEnd }. + * Throws SubsetCancelled on invalid/cancelled input (already alerted). + */ + async function resolveTimeRange(run) { const subsetTimeMode = getSubsetTimeMode(); const useFull = subsetTimeMode === 'full'; const useCurrent = subsetTimeMode === 'current'; - let rangeStart = ''; - let rangeEnd = ''; + const timeMode = useCurrent ? 'current' : (useFull ? 'full' : 'range'); + if (useCurrent) { const selectedTime = getSelectedTime(); if (!selectedTime || selectedTime === '—') { - setSubsetDownloadBusy(false); alert('No selected time available for this dataset.'); logger.finishSubsetRun(run, 'cancelled', { reason: 'missing-selected-time' }); - return; + throw new SubsetCancelled(); } - } else if (useFull) { - rangeStart = state.selectedLayer?.time?.start || state.times?.[0] || ''; - rangeEnd = state.selectedLayer?.time?.end || state.times?.[state.times.length - 1] || ''; - if (state.times.length > NCSS_WARN_TIMESTEPS) { - const proceed = window.confirm(`Full-range subset will request ${state.times.length} timesteps and may time out. Continue?`); - if (!proceed) { - setSubsetDownloadBusy(false); - logger.finishSubsetRun(run, 'cancelled', { reason: 'user-cancelled-full-time-warning' }); - return; - } - } - } else { - const startIso = parseSubsetDateValue(subsetTimeStart.value, 'start'); - const endIso = parseSubsetDateValue(subsetTimeEnd.value, 'end'); - if (startIso === null || endIso === null) { - setSubsetDownloadBusy(false); - alert('Please enter dates as YYYY, YYYY-MM, YYYY-MM-DD (or with / separators).'); - logger.finishSubsetRun(run, 'cancelled', { reason: 'invalid-date-input' }); - return; - } - rangeStart = startIso || ''; - rangeEnd = endIso || ''; - if (rangeStart && rangeEnd && Date.parse(rangeStart) > Date.parse(rangeEnd)) { - setSubsetDownloadBusy(false); - alert('Start date must be before end date.'); - logger.finishSubsetRun(run, 'cancelled', { reason: 'invalid-date-range' }); - return; + return { timeMode, rangeStart: '', rangeEnd: '' }; + } + + if (useFull) { + const rangeStart = state.selectedLayer?.time?.start || state.times?.[0] || ''; + const rangeEnd = state.selectedLayer?.time?.end || state.times?.[state.times.length - 1] || ''; + return { timeMode, rangeStart, rangeEnd }; + } + + const startIso = parseSubsetDateValue(subsetTimeStart.value, 'start'); + const endIso = parseSubsetDateValue(subsetTimeEnd.value, 'end'); + if (startIso === null || endIso === null) { + alert('Please enter dates as YYYY, YYYY-MM, YYYY-MM-DD (or with / separators).'); + logger.finishSubsetRun(run, 'cancelled', { reason: 'invalid-date-input' }); + throw new SubsetCancelled(); + } + const rangeStart = startIso || ''; + const rangeEnd = endIso || ''; + if (rangeStart && rangeEnd && Date.parse(rangeStart) > Date.parse(rangeEnd)) { + alert('Start date must be before end date.'); + logger.finishSubsetRun(run, 'cancelled', { reason: 'invalid-date-range' }); + throw new SubsetCancelled(); + } + + const fullRangeStart = state.selectedLayer?.time?.start || state.times?.[0] || ''; + const fullRangeEnd = state.selectedLayer?.time?.end || state.times?.[state.times.length - 1] || fullRangeStart; + if (state.times.length) { + const [timeStartIndex, timeEndIndex] = indexController.findTimeIndexRange(state.times, rangeStart || fullRangeStart, rangeEnd || fullRangeEnd); + if (timeStartIndex === 0 && timeEndIndex === (state.times.length - 1)) { + return { timeMode: 'full', rangeStart: fullRangeStart, rangeEnd: fullRangeEnd }; } } + return { timeMode, rangeStart, rangeEnd }; + } + + /** + * Fast path: the whole spatial domain and full time range were requested, + * so we can just download the original file directly via HTTPServer + * instead of going through ncpartitioner. + */ + async function runFullFileDownload(run, spatialMode) { + const fullFileUrl = fileServerUrlForUrlPath(state.currentDataset.urlPath); + const perfStart = Date.now(); + startStatusSpinner('Starting full-file download (HTTPServer)…'); + triggerBackgroundDownload(fullFileUrl); + stopStatusSpinner('Full-file download started.'); + setSubsetDownloadBusy(false); + try { - if (useWholeSpatialDomain && useFull) { - const fullFileUrl = fileServerUrlForUrlPath(state.currentDataset.urlPath); - const perfStart = Date.now(); - startStatusSpinner('Starting full-file download (HTTPServer)…'); - triggerBackgroundDownload(fullFileUrl); - stopStatusSpinner('Full-file download started.'); - setSubsetDownloadBusy(false); + const head = await fetch(`${fullFileUrl}?_ts=${Date.now()}`, { method: 'HEAD', cache: 'no-store' }); + const bytes = Number(head.headers.get('content-length') || 0); + const elapsedMs = Date.now() - perfStart; + logger.logSubsetPerf({ + mode: ROUTE.FULL_FILE, + file: state.currentDataset.urlPath, + elapsedMs, + bytes, + bytesPerSec: (bytes > 0 && elapsedMs > 0) ? (bytes / (elapsedMs / 1000)) : null, + at: new Date().toISOString() + }); + } catch { /* perf logging is best-effort — don't let it block the download */ } + + logger.finishSubsetRun(run, 'ok', { route: ROUTE.FULL_FILE, spatialMode, timeMode: 'full', file: state.currentDataset.urlPath }); + } + + /** Converts the resolved bbox/time range into ncpartitioner array indexes. */ + function resolveNcpartitionerIndexes(bbox, useWholeSpatialDomain, indexInfo) { + const [latStart, latEnd] = useWholeSpatialDomain + ? [0, Math.max(0, indexInfo.lat.length - 1)] + : indexController.findBoundedIndexRange(indexInfo.lat, bbox.south, bbox.north); + const [lonStart, lonEnd] = useWholeSpatialDomain + ? [0, Math.max(0, indexInfo.lon.length - 1)] + : indexController.findBoundedIndexRange(indexInfo.lon, bbox.west, bbox.east); + return { latStart, latEnd, lonStart, lonEnd }; + } + + /** Submits the ncpartitioner job and returns the parsed job + status URL. */ + async function submitNcpartitionerJob(targets, signal) { + const filepath = /\.nc4?$/i.test(state.currentDataset.urlPath) + ? state.currentDataset.urlPath + : `${state.currentDataset.urlPath}.nc`; + const partitionParams = new URLSearchParams(); + partitionParams.set('filepath', filepath); + partitionParams.set('targets', targets); + const partitionRequestUrl = new URL(`partition/?${partitionParams.toString()}`, ncpartitionerPublicRoot).toString(); + + startStatusSpinner('Submitting subset to ncpartitioner…'); + const response = await fetch(partitionRequestUrl, { method: 'GET', signal }); + if (response.status !== 202) { + throw new Error(`Unexpected ncpartitioner response: ${response.status}`); + } + + const job = await response.json(); + if (!job?.job_id || !job?.status_url) { + throw new Error('Invalid ncpartitioner job response'); + } + const statusUrl = new URL(job.status_url, ncpartitionerPublicRoot).toString(); + return { job, statusUrl }; + } + + /** + * Polls the ncpartitioner job until it completes, fails, or the run is + * superseded/cancelled. On success, triggers the download and logs perf. + */ + async function pollNcpartitionerJob({ run, job, statusUrl, spatialMode, timeMode, indexMs, tPartitionStart }) { + let lastStatusMessage = waitingStatusMessage(queuePositionFor(job)); + let isLongWait = false; + startStatusSpinner(lastStatusMessage); + const stopBackgroundStatus = startBackgroundSubsetStatus(run.id); + const requestStartedAt = Date.now(); + + while (activeNcPollRunId === run.id) { + const statusResponse = await fetch(statusUrl, { cache: 'no-store', signal: activeFetchController?.signal }); + if (activeNcPollRunId !== run.id) return; + + if (statusResponse.status === 404) { + throw new Error('Subset job not found'); + } + if (!statusResponse.ok) { + throw new Error(`Unexpected subset status response: ${statusResponse.status}`); + } + + const statusPayload = await statusResponse.json(); + + if (statusPayload.status === 'complete') { + const downloadUrl = statusPayload.download_url || job.download_url; + if (!downloadUrl) throw new Error('Subset completed without a download URL'); + + activeNcPollRunId = null; + activeSubsetRunId = null; + stopBackgroundStatus(); + stopStatusSpinner('Subset complete. Starting download…'); + triggerBackgroundDownload(downloadUrl); + + let bytes = 0; try { - const head = await fetch(`${fullFileUrl}?_ts=${Date.now()}`, { method: 'HEAD', cache: 'no-store' }); - const bytes = Number(head.headers.get('content-length') || 0); - const elapsedMs = Date.now() - perfStart; - logger.logSubsetPerf({ - mode: 'httpserver-full-file', - file: state.currentDataset.urlPath, - elapsedMs, - bytes, - bytesPerSec: (bytes > 0 && elapsedMs > 0) ? (bytes / (elapsedMs / 1000)) : null, - at: new Date().toISOString() - }); - } catch { /* perf logging is best-effort — don't let it block the download */ } - logger.finishSubsetRun(run, 'ok', { route: 'httpserver-full-file', spatialMode, timeMode: 'full', file: state.currentDataset.urlPath }); + const head = await fetch(`${downloadUrl}?_ts=${Date.now()}`, { method: 'HEAD', cache: 'no-store' }); + bytes = Number(head.headers.get('content-length') || 0); + } catch { /* perf logging is best-effort */ } + + const tPartitionEnd = performance.now(); + logger.logSubsetPerf({ + mode: ROUTE.NCPARTITIONER, + file: state.currentDataset.urlPath, + output: job.output_filename || null, + elapsedMs: Math.round(tPartitionEnd - tPartitionStart), + bytes, + bytesPerSec: (bytes > 0 && (tPartitionEnd - tPartitionStart) > 0) ? (bytes / ((tPartitionEnd - tPartitionStart) / 1000)) : null, + spatialMode, + timeMode, + at: new Date().toISOString() + }); + logger.finishSubsetRun(run, 'ok', { + route: ROUTE.NCPARTITIONER, + spatialMode, + timeMode, + indexMs, + jobId: job.job_id, + file: state.currentDataset.urlPath + }); return; } - const tIndexStart = performance.now(); - startStatusSpinner('Converting bounds/time to ncpartitioner indexes…'); + if (statusPayload.status === 'failed') { + throw new Error(statusPayload.error || 'Subset generation failed'); + } + if (statusPayload.status !== 'running' && statusPayload.status !== 'queued') { + throw new Error(`Unexpected subset job status: ${statusPayload.status || 'unknown'}`); + } + + const nextIsLongWait = (Date.now() - requestStartedAt) >= BACKGROUND_STATUS_TIMEOUT_MS; + const nextStatusMessage = statusPayload.status === 'queued' + ? waitingStatusMessage(queuePositionFor(statusPayload), nextIsLongWait) + : runningStatusMessage(statusPayload, nextIsLongWait); + if (nextStatusMessage !== lastStatusMessage || nextIsLongWait !== isLongWait) { + lastStatusMessage = nextStatusMessage; + isLongWait = nextIsLongWait; + startStatusSpinner(nextStatusMessage); + } + + const pollDelay = nextIsLongWait + ? BACKGROUND_STATUS_SLOW_POLL_MS + : BACKGROUND_STATUS_POLL_MS; + await sleep(pollDelay); + } + } + + /** Builds the ncpartitioner request and drives it through to download. */ + async function runNcpartitionerSubset(run, { bbox, useWholeSpatialDomain, spatialMode, timeMode, rangeStart, rangeEnd, useCurrent }) { + const fetchController = replaceActiveFetchController(); + const tIndexStart = performance.now(); + startStatusSpinner('Converting bounds/time to ncpartitioner indexes…'); + try { const indexInfo = await indexController.getNcpartitionerIndexInfo(state.currentDataset.urlPath); - const [latStart, latEnd] = useWholeSpatialDomain - ? [0, Math.max(0, indexInfo.lat.length - 1)] - : indexController.findBoundedIndexRange(indexInfo.lat, bbox.south, bbox.north); - const [lonStart, lonEnd] = useWholeSpatialDomain - ? [0, Math.max(0, indexInfo.lon.length - 1)] - : indexController.findBoundedIndexRange(indexInfo.lon, bbox.west, bbox.east); + const { latStart, latEnd, lonStart, lonEnd } = resolveNcpartitionerIndexes(bbox, useWholeSpatialDomain, indexInfo); const tIndexEnd = performance.now(); + let effectiveTimeMode = timeMode; + const actualTimeCount = Math.max(0, Number(indexInfo.timeCount || 0)); let timeStartIso = ''; let timeEndIso = ''; @@ -278,75 +454,112 @@ export function createSubsetDownloadController({ timeEndIso = state.times?.[state.times.length - 1] || timeStartIso; } - const [timeStart, timeEnd] = indexController.findTimeIndexRange(state.times || [], timeStartIso, timeEndIso); + let [timeStart, timeEnd] = indexController.findTimeIndexRange(state.times || [], timeStartIso, timeEndIso); + if (actualTimeCount > 0) { + timeStart = Math.max(0, Math.min(timeStart, actualTimeCount - 1)); + timeEnd = Math.max(timeStart, Math.min(timeEnd, actualTimeCount - 1)); + } + + const totalTimesteps = actualTimeCount || (Array.isArray(state.times) ? state.times.length : 0); + const selectedTimesteps = (timeEnd - timeStart) + 1; + const requestedFraction = totalTimesteps > 0 ? (selectedTimesteps / totalTimesteps) : 0; + + if (timeMode !== 'full' && totalTimesteps > 0 && requestedFraction > FULL_TIME_SUGGESTION_THRESHOLD) { + const choice = await promptLargeSubsetChoice(requestedFraction); + if (choice === 'cancel') { + logger.finishSubsetRun(run, 'cancelled', { reason: 'user-dismissed-large-subset-choice' }); + throw new SubsetCancelled('Subset request cancelled.'); + } + if (choice === 'full') { + timeStart = 0; + timeEnd = totalTimesteps - 1; + effectiveTimeMode = 'full'; + if (useWholeSpatialDomain) { + await runFullFileDownload(run, spatialMode); + return; + } + } + } + const targets = [ `time[${timeStart}:${timeEnd}]`, `lat[${latStart}:${latEnd}]`, `lon[${lonStart}:${lonEnd}]`, `${state.variable}[${timeStart}:${timeEnd}][${latStart}:${latEnd}][${lonStart}:${lonEnd}]` ].join(','); - const filepath = `${state.currentDataset.urlPath}.nc`; - const partitionParams = new URLSearchParams(); - partitionParams.set('filepath', filepath); - partitionParams.set('targets', targets); - const partitionUrl = `${ncpartitionerBase()}?${partitionParams.toString()}`; + const tPartitionStart = performance.now(); - startStatusSpinner('Building subset with ncpartitioner…'); - const startUnixSec = Math.floor(Date.now() / 1000); - const outputBaseName = state.currentDataset.urlPath.replace(/^.*\//, '').replace(/\.(nc|nc4)$/i, ''); - triggerBackgroundDownload(partitionUrl); - stopStatusSpinner(); - const stopBackgroundStatus = startBackgroundSubsetStatus(run.id); - logger.finishSubsetRun(run, 'ok', { - route: 'ncpartitioner', + const { job, statusUrl } = await submitNcpartitionerJob(targets, fetchController.signal); + + await pollNcpartitionerJob({ + run, + job, + statusUrl, spatialMode, - timeMode: useCurrent ? 'current' : (useFull ? 'full' : 'range'), + timeMode: effectiveTimeMode, indexMs: Math.round(tIndexEnd - tIndexStart), - file: state.currentDataset.urlPath + tPartitionStart }); - void (async () => { - try { - const artifact = await waitForOutputArtifact({ - basename: outputBaseName, - startUnixSec, - timeoutMs: BACKGROUND_STATUS_TIMEOUT_MS - }); - const downloadUrl = `${threddsRoot()}fileServer/${artifact.urlPath}`; - const head = await fetch(`${downloadUrl}?_ts=${Date.now()}`, { method: 'HEAD', cache: 'no-store' }); - const bytes = Number(head.headers.get('content-length') || 0); - const tPartitionEnd = performance.now(); - stopBackgroundStatus(); - setStatus('Generation complete, starting download'); - logger.logSubsetPerf({ - mode: 'ncpartitioner', - file: state.currentDataset.urlPath, - output: artifact.name, - elapsedMs: Math.round(tPartitionEnd - tPartitionStart), - bytes, - bytesPerSec: (bytes > 0 && (tPartitionEnd - tPartitionStart) > 0) ? (bytes / ((tPartitionEnd - tPartitionStart) / 1000)) : null, - spatialMode, - timeMode: useCurrent ? 'current' : (useFull ? 'full' : 'range'), - at: new Date().toISOString() - }); - } catch (artifactError) { - stopBackgroundStatus(); - setStatus('Subset is still processing in the background. Check browser downloads; server confirmation may lag.'); - console.warn('Could not verify ncpartitioner output artifact:', artifactError); - } - })(); + } finally { + clearActiveFetchController(fetchController); + } + } + + async function downloadSubset() { + if (activeSubsetRunId) { + setStatus('A subset request is already running.'); return; + } + if (subsetDownloadBtn.disabled) return; + if (!state.currentDataset) return alert('Please select a dataset first'); + if (!state.variable) return alert('Could not infer variable for this file.'); + + setSubsetDownloadBusy(true); + const run = logger.startSubsetRun('subset-download', { portal: portal.id, dataset: state.currentDataset?.urlPath || null }); + activeSubsetRunId = run.id; + activeNcPollRunId = run.id; + + const spatialMode = (subsetSpatialMode?.value || 'viewport').toLowerCase(); + let timeMode = 'range'; + + try { + const { bbox, useWholeSpatialDomain } = resolveSpatialExtent(spatialMode, run); + const { timeMode: resolvedTimeMode, rangeStart, rangeEnd } = await resolveTimeRange(run); + timeMode = resolvedTimeMode; + + if (useWholeSpatialDomain && timeMode === 'full') { + await runFullFileDownload(run, spatialMode); + return; + } + + await runNcpartitionerSubset(run, { + bbox, + useWholeSpatialDomain, + spatialMode, + timeMode, + rangeStart, + rangeEnd, + useCurrent: timeMode === 'current' + }); } catch (error) { + if (error instanceof SubsetCancelled || error?.name === 'AbortError') { + cancelPendingSubsetStatus(error instanceof SubsetCancelled ? error.message : ''); + return; + } + activeNcPollRunId = null; + activeSubsetRunId = null; console.error(error); cancelPendingSubsetStatus(); stopStatusSpinner(`Subset failed: ${error?.message || error}`, true); logger.finishSubsetRun(run, 'error', { - route: 'ncpartitioner', + route: ROUTE.NCPARTITIONER, spatialMode, - timeMode: useCurrent ? 'current' : (useFull ? 'full' : 'range'), + timeMode, error: String(error?.message || error || 'unknown') }); alert(`Subset failed: ${error?.message || error}`); } finally { + if (activeSubsetRunId === run.id && !activeBackgroundStatus) activeSubsetRunId = null; if (!activeBackgroundStatus) setSubsetDownloadBusy(false); } } diff --git a/viewer/js/subsetting/indexes.js b/viewer/js/subsetting/indexes.js index ec9794f..0a96e1e 100644 --- a/viewer/js/subsetting/indexes.js +++ b/viewer/js/subsetting/indexes.js @@ -89,11 +89,12 @@ export function createSubsetIndexController({ async function getNcpartitionerIndexInfo(urlPath) { const key = String(urlPath || ''); if (state.ncpIndexCache[key]) return state.ncpIndexCache[key]; - const [lat, lon] = await Promise.all([ + const [lat, lon, time] = await Promise.all([ fetchOpendapDimensionValues(urlPath, 'lat'), - fetchOpendapDimensionValues(urlPath, 'lon') + fetchOpendapDimensionValues(urlPath, 'lon'), + fetchOpendapDimensionValues(urlPath, 'time') ]); - const indexInfo = { lat, lon }; + const indexInfo = { lat, lon, timeCount: time.length }; state.ncpIndexCache[key] = indexInfo; return indexInfo; } diff --git a/viewer/pdp-next-viewer.html b/viewer/pdp-next-viewer.html index 6ed31e6..4df0a69 100644 --- a/viewer/pdp-next-viewer.html +++ b/viewer/pdp-next-viewer.html @@ -44,7 +44,7 @@

Climate Layer Opacity

- Ready - Select a dataset + Ready
@@ -203,6 +203,17 @@

Subsetting

+ +
+

Large Time Selection

+

+
+ + +
+
+
+ diff --git a/viewer/styles/controls.css b/viewer/styles/controls.css index 43b5d48..0afcfb4 100644 --- a/viewer/styles/controls.css +++ b/viewer/styles/controls.css @@ -222,3 +222,43 @@ gap: 10px; margin-top: 10px; } + +.choice-dialog { + border: none; + border-radius: var(--radius-sm); + max-width: 480px; + padding: 0; + width: calc(100% - 32px); +} + +.choice-dialog::backdrop { + background: rgba(15, 23, 42, 0.45); +} + +.choice-dialog-card { + background: var(--bg-panel); + display: flex; + flex-direction: column; + gap: 16px; + margin: 0; + padding: 20px; +} + +.choice-dialog-title { + color: var(--text-dark); + font-size: 18px; + margin: 0; +} + +.choice-dialog-message { + color: var(--text-muted); + font-size: 14px; + line-height: 1.5; + margin: 0; +} + +.choice-dialog-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} diff --git a/viewer/styles/layout.css b/viewer/styles/layout.css index 640bf9b..33e693e 100644 --- a/viewer/styles/layout.css +++ b/viewer/styles/layout.css @@ -160,9 +160,14 @@ .status-bar { right: 20px; bottom: 60px; - padding: 8px 12px; + max-width: min(460px, calc(100% - 40px)); + min-width: 220px; + padding: 12px 16px; border-radius: var(--radius-sm); box-shadow: var(--shadow-status); color: var(--text-muted); - font-size: 11px; + font-size: 13px; + font-weight: 500; + line-height: 1.45; + white-space: normal; }