diff --git a/.gitignore b/.gitignore index e11cba7..e80b6eb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,16 @@ __pycache__/ .env */logs/* */ncpartitioner-output/* -node_modules/ \ No newline at end of file +node_modules/ +portal-meta/bccaqv2_u5.json +portal-meta/bccaqv2_u6.json +portal-meta/canada_mosaic.json +portal-meta/canesm5_m6.json +portal-meta/canesm5_u6.json +portal-meta/gridded_daily.json +portal-meta/mbcn.json +portal-meta/prism.json +portal-meta/vicgl.json +portal-prep/db-export.csv +.gitignore +portal-prep/pdp_min_max.csv diff --git a/README.md b/README.md index e5b0626..97da5bc 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,28 @@ menu rules from `portal_meta_builder/portals.py`. ## Scripts +### Run the frontend locally + +Run the local viewer while proxying portal metadata, THREDDS, and +ncpartitioner requests to Beehive: + +```bash +npm run dev +``` + +Then open . Local files under `viewer/` are +served without caching, while `/pdp-next/portal-meta/`, +`/pdp-next/thredds/`, and `/pdp-next/ncpartitioner/` are fetched through the +same-origin development proxy. + +The defaults can be overridden when needed: + +```bash +PDP_DEV_PORT=8080 \ +PDP_DEV_UPSTREAM=https://beehive.pacificclimate.org \ +npm run dev +``` + ### Build hardlink mirror ```bash diff --git a/package.json b/package.json index c5c0286..8b9704a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "pdp-next", "private": true, "scripts": { + "dev": "node scripts/dev-viewer.mjs", "lint": "eslint 'viewer/js/**/*.js' 'viewer/js/*.js'", "lint:fix": "eslint --fix 'viewer/js/**/*.js' 'viewer/js/*.js'", "lint:ci": "eslint --max-warnings=999 'viewer/js/**/*.js' 'viewer/js/*.js'" @@ -10,4 +11,4 @@ "@eslint/js": "^9.0.0", "eslint": "^9.0.0" } -} \ No newline at end of file +} diff --git a/scripts/dev-viewer.mjs b/scripts/dev-viewer.mjs new file mode 100644 index 0000000..95ec4ac --- /dev/null +++ b/scripts/dev-viewer.mjs @@ -0,0 +1,146 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { extname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); +const viewerRoot = resolve(repositoryRoot, 'viewer'); +const host = process.env.PDP_DEV_HOST || '127.0.0.1'; +const port = Number.parseInt(process.env.PDP_DEV_PORT || '4173', 10); +const upstream = new URL( + process.env.PDP_DEV_UPSTREAM || 'https://beehive.pacificclimate.org', +); + +if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid PDP_DEV_PORT: ${process.env.PDP_DEV_PORT}`); +} +if (!['http:', 'https:'].includes(upstream.protocol)) { + throw new Error('PDP_DEV_UPSTREAM must use http or https.'); +} + +const mimeTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', +}; + +function localAssetPath(pathname) { + if (pathname === '/pdp-next/' || pathname === '/pdp-next/pdp-next-viewer.html') { + return resolve(viewerRoot, 'pdp-next-viewer.html'); + } + if (pathname === '/pdp-next/viewer.css') return resolve(viewerRoot, 'viewer.css'); + if (pathname === '/pdp-next/favicon.ico') return resolve(viewerRoot, 'favicon.ico'); + + const assetMatch = pathname.match(/^\/pdp-next\/(js|styles)\/(.+)$/); + if (!assetMatch) return null; + let relativePath; + try { + relativePath = decodeURIComponent(`${assetMatch[1]}/${assetMatch[2]}`); + } catch { + return null; + } + const assetPath = resolve(viewerRoot, relativePath); + const viewerPrefix = `${viewerRoot}${sep}`; + return assetPath.startsWith(viewerPrefix) ? assetPath : null; +} + +async function serveFile(request, response, filePath) { + try { + const details = await stat(filePath); + if (!details.isFile()) throw new Error('Not a file'); + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': details.size, + 'content-type': mimeTypes[extname(filePath).toLowerCase()] || 'application/octet-stream', + }); + if (request.method === 'HEAD') response.end(); + else createReadStream(filePath).pipe(response); + } catch { + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('Not found\n'); + } +} + +function proxyRequest(request, response) { + const target = new URL(request.url, upstream); + const requestImpl = target.protocol === 'https:' ? httpsRequest : httpRequest; + const headers = { + ...request.headers, + host: target.host, + 'x-forwarded-host': request.headers.host || '', + 'x-forwarded-proto': 'http', + }; + + const proxied = requestImpl(target, { + method: request.method, + headers, + }, (upstreamResponse) => { + const responseHeaders = { ...upstreamResponse.headers }; + if (responseHeaders.location) { + responseHeaders.location = responseHeaders.location.replace( + upstream.origin, + `http://${request.headers.host}`, + ); + } + response.writeHead(upstreamResponse.statusCode || 502, responseHeaders); + upstreamResponse.pipe(response); + }); + + proxied.on('error', (error) => { + if (response.headersSent) { + response.destroy(error); + return; + } + response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }); + response.end(`Could not reach ${upstream.origin}: ${error.message}\n`); + }); + request.on('aborted', () => proxied.destroy()); + request.pipe(proxied); +} + +const server = createServer(async (request, response) => { + const requestUrl = new URL(request.url, `http://${request.headers.host || host}`); + if (requestUrl.pathname === '/') { + response.writeHead(302, { location: '/pdp-next/' }); + response.end(); + return; + } + + const assetPath = localAssetPath(requestUrl.pathname); + if (assetPath) { + await serveFile(request, response, assetPath); + return; + } + + const isBackendRoute = [ + '/pdp-next/portal-meta/', + '/pdp-next/thredds/', + '/pdp-next/ncpartitioner/', + ].some((prefix) => requestUrl.pathname.startsWith(prefix)); + if (isBackendRoute) { + proxyRequest(request, response); + return; + } + + if (requestUrl.pathname.startsWith('/pdp-next/')) { + await serveFile(request, response, resolve(viewerRoot, 'pdp-next-viewer.html')); + return; + } + + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('Not found\n'); +}); + +server.listen(port, host, () => { + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + console.log(`PDP viewer: http://${displayHost}:${port}/pdp-next/`); + console.log(`Backend proxy: ${upstream.origin}`); +}); diff --git a/thredds/Dockerfile.thredds b/thredds/Dockerfile.thredds index eda0ee0..c1825ae 100644 --- a/thredds/Dockerfile.thredds +++ b/thredds/Dockerfile.thredds @@ -1,4 +1,4 @@ -FROM unidata/thredds-docker:5.6 +FROM unidata/thredds-docker:5.9 # Bake in a minimal, generic catalog. # Mount /data/* volumes at runtime. diff --git a/thredds/thredds-config/threddsConfig.xml b/thredds/thredds-config/threddsConfig.xml index e42e6e4..2a5274e 100644 --- a/thredds/thredds-config/threddsConfig.xml +++ b/thredds/thredds-config/threddsConfig.xml @@ -13,6 +13,12 @@ true + + + true + false + true + true diff --git a/viewer/js/app.js b/viewer/js/app.js index 1313b0b..4abc4ff 100644 --- a/viewer/js/app.js +++ b/viewer/js/app.js @@ -4,8 +4,10 @@ import { CRS_OPTIONS, DEFAULT_CANADA_BBOX_4326, buildDefaultPortalConfig, + buildViewerUrl, readPortalId, readDefaultPortalId, + readViewerUrlState, } from "./core/config.js"; import { createTimeController } from "./time.js"; import { createMenuController } from "./portal/menu.js"; @@ -24,6 +26,7 @@ import { timeValue, datasetName, variableInfo, + selectionVariableIcon, timeInfo, paletteSelect, scaleMin, @@ -59,6 +62,7 @@ import { const requestedPortalId = readPortalId(); const resolvedPortalId = requestedPortalId || readDefaultPortalId(); +const initialUrlState = readViewerUrlState(); let portal = buildDefaultPortalConfig(resolvedPortalId); let groups = Array.isArray(portal.groups) ? portal.groups : []; @@ -205,11 +209,105 @@ const { updateMap, setLayerOpacity, fitMapToBbox4326, + getViewBbox4326, populateCrsSelect, } = mapController; const refreshInfoPanel = () => - updateInfoPanel(datasetName, variableInfo, timeInfo); + updateInfoPanel(datasetName, variableInfo, timeInfo, selectionVariableIcon); + +let initialViewerStatePending = true; +let viewerUrlReady = false; +let viewerUrlTimer = null; + +function selectHasValue(select, value) { + return Array.from(select?.options || []).some((option) => option.value === value); +} + +function applyInitialViewerState() { + if (!initialViewerStatePending) return; + + if (initialUrlState.crs && ol.proj.get(initialUrlState.crs)) { + setMapProjection(initialUrlState.crs); + crsSelect.value = getCurrentCrs(); + } + if (initialUrlState.style && selectHasValue(styleSelect, initialUrlState.style)) { + styleSelect.value = initialUrlState.style; + } + if (initialUrlState.palette && selectHasValue(paletteSelect, initialUrlState.palette)) { + paletteSelect.value = initialUrlState.palette; + } + if (initialUrlState.min !== null) scaleMin.value = String(initialUrlState.min); + if (initialUrlState.max !== null) scaleMax.value = String(initialUrlState.max); + if (initialUrlState.colors !== null) { + numColors.value = String(Math.min(254, Math.max(2, initialUrlState.colors))); + } + if (initialUrlState.opacity !== null) { + opacitySlider.value = String( + Math.min(100, Math.max(0, initialUrlState.opacity)), + ); + } + if (initialUrlState.time) { + let timeIndex = state.times.indexOf(initialUrlState.time); + if (timeIndex < 0) { + const wantedTime = Date.parse(initialUrlState.time); + if (Number.isFinite(wantedTime)) { + timeIndex = state.times.findIndex( + (value) => Date.parse(value) === wantedTime, + ); + } + } + if (timeIndex >= 0) timeSlider.value = String(timeIndex); + } + updateTimeUI(); + syncPaletteEnabled(); + + if (initialUrlState.view) { + const [west, south, east, north] = initialUrlState.view; + fitMapToBbox4326({ west, south, east, north }); + } + initialViewerStatePending = false; +} + +function currentViewerUrlState() { + const bbox = getViewBbox4326(); + const selectedTime = getSelectedTime(); + return { + dataset: state.currentDataset?.urlPath || null, + variable: state.selectedLayer?.name || state.variable, + view: bbox ? [bbox.west, bbox.south, bbox.east, bbox.north] : null, + crs: getCurrentCrs(), + palette: paletteSelect.value, + style: styleSelect.value, + min: scaleMin.value === '' ? null : Number(scaleMin.value), + max: scaleMax.value === '' ? null : Number(scaleMax.value), + colors: Number(numColors.value), + opacity: Number(opacitySlider.value), + time: selectedTime === '—' ? null : selectedTime, + }; +} + +function scheduleViewerUrlSync() { + if (!viewerUrlReady || !state.currentDataset) return; + if (viewerUrlTimer) window.clearTimeout(viewerUrlTimer); + viewerUrlTimer = window.setTimeout(() => { + const nextUrl = buildViewerUrl( + resolvedPortalId, + currentViewerUrlState(), + ); + if (nextUrl !== window.location.href) { + window.history.replaceState(null, '', nextUrl); + } + viewerUrlTimer = null; + }, 120); +} + +function markViewerUrlReady() { + viewerUrlReady = true; + scheduleViewerUrlSync(); +} + +map.on('moveend', scheduleViewerUrlSync); let cancelPendingSubsetStatus = () => {}; @@ -256,6 +354,8 @@ const datasetController = createDatasetController({ render: { refreshInfoPanel, updateMap, + applyInitialViewerState, + viewerStateChanged: markViewerUrlReady, }, }); @@ -269,6 +369,8 @@ const { const menuController = createMenuController({ portal, + initialDatasetUrlPath: initialUrlState.dataset, + initialVariable: initialUrlState.variable, ui: { datasetMenu, portalSelect, @@ -344,7 +446,7 @@ async function setActiveGroup(groupId) { wireEvents({ state, - requestedPortalId, + activePortalId: resolvedPortalId, getSubsetTimeMode, getSelectedTimeIndex, getSelectedTimeLabel, @@ -359,16 +461,25 @@ wireEvents({ syncPaletteEnabled, setMapProjection, getCurrentCrs, - fitMapToBbox4326, setSubsetDrawMode, clearSubsetDrawing, downloadSubset, + viewerStateChanged: scheduleViewerUrlSync, }); updateViewerTitle(); populatePortalSelect(); populateCrsSelect(CRS_OPTIONS); -fitMapToBbox4326(DEFAULT_CANADA_BBOX_4326); +if (initialUrlState.crs && ol.proj.get(initialUrlState.crs)) { + setMapProjection(initialUrlState.crs); + crsSelect.value = getCurrentCrs(); +} +if (initialUrlState.view) { + const [west, south, east, north] = initialUrlState.view; + fitMapToBbox4326({ west, south, east, north }); +} else { + fitMapToBbox4326(DEFAULT_CANADA_BBOX_4326); +} subsetSpatialMode.value = state.subset.spatialMode; setSubsetDrawMode(state.subset.spatialMode); updateSubsetTimeInputsEnabled(); diff --git a/viewer/js/core/config.js b/viewer/js/core/config.js index be004bc..2170803 100644 --- a/viewer/js/core/config.js +++ b/viewer/js/core/config.js @@ -1,61 +1,103 @@ export const KNOWN_PORTALS = [ // { id: 'gridded_daily', title: 'Daily Gridded Meteorological Datasets', mount: 'gridded_daily', defaultCrs: 'EPSG:4326' }, - { id: 'prism', title: 'BC PRISM', mount: 'prism', defaultCrs: 'EPSG:3005' }, - { id: 'canada_mosaic', title: 'Canada Mosaic', mount: 'canada_mosaic', defaultCrs: 'EPSG:3978' }, - // { id: 'vicgl', title: 'Gridded Hydrologic Model Output (VICGL)', mount: 'vicgl', defaultCrs: 'EPSG:3005' }, - // { id: 'bccaqv2_u5', title: 'CanDCS-U5 (BCCAQv2 CMIP5)', mount: 'bccaqv2_u5', defaultCrs: 'EPSG:4326' }, - // { id: 'bccaqv2_u6', title: 'CanDCS-U6 (BCCAQv2 CMIP6)', mount: 'bccaqv2_u6', defaultCrs: 'EPSG:4326' }, - // { id: 'mbcn', title: 'Canadian Downscaled Climate Scenarios (MBCn)', mount: 'mbcn', defaultCrs: 'EPSG:3978' }, - // { id: 'canesm5_u6', title: 'CanESM5 (Univariate)', mount: 'bccaqv2/canesm5', defaultCrs: 'EPSG:4326' }, - // { id: 'canesm5_m6', title: 'CanESM5 (Multivariate)', mount: 'mbcn/canesm5_10', defaultCrs: 'EPSG:3978' } + { id: "prism", title: "BC PRISM", mount: "prism", defaultCrs: "EPSG:3005" }, + { + id: "canada_mosaic", + title: "Canada Mosaic", + mount: "canada_mosaic", + defaultCrs: "EPSG:3978", + }, + { + id: "vicgl", + title: "Gridded Hydrologic Model Output (VICGL)", + mount: "vicgl", + defaultCrs: "EPSG:3005", + }, + { + id: "bccaqv2_u5", + title: "CanDCS-U5 (BCCAQv2 CMIP5)", + mount: "bccaqv2_u5", + defaultCrs: "EPSG:4326", + }, + { + id: "bccaqv2_u6", + title: "CanDCS-U6 (BCCAQv2 CMIP6)", + mount: "bccaqv2_u6", + defaultCrs: "EPSG:4326", + }, + { + id: "mbcn", + title: "Canadian Downscaled Climate Scenarios (MBCn)", + mount: "mbcn", + defaultCrs: "EPSG:3978", + }, + { + id: "canesm5_u6", + title: "CanESM5 (Univariate)", + mount: "bccaqv2/canesm5", + defaultCrs: "EPSG:4326", + }, + { + id: "canesm5_m6", + title: "CanESM5 (Multivariate)", + mount: "mbcn/canesm5_10", + defaultCrs: "EPSG:3978", + }, ]; -export const PORTAL_PARAM_KEY = 'portal'; -export const WMS_VERSION = '1.3.0'; +export const PORTAL_PARAM_KEY = "portal"; +export const WMS_VERSION = "1.3.0"; export const TIME_EXPAND_LIMIT = 2000; export const NCSS_WARN_TIMESTEPS = 1500; -export const DEFAULT_PORTAL_ID = 'canada_mosaic'; +export const DEFAULT_PORTAL_ID = "canada_mosaic"; export const PALETTE_LABELS = { - default: 'Default', - 'seq-Blues': 'Sequential Blues', - 'seq-BuGn': 'Sequential Blue-Green', - 'seq-GnBu': 'Sequential Green-Blue', - 'seq-Greens': 'Sequential Greens', - 'seq-YlOrRd': 'Sequential Yellow-Orange-Red', - 'seq-OrRd': 'Sequential Orange-Red', - 'seq-Reds': 'Sequential Reds', - 'seq-Heat': 'Sequential Heat', - 'seq-viridis': 'Viridis (sequential)', - 'psu-viridis': 'PSU Viridis', - 'div-Spectral': 'Diverging Spectral', - 'div-RdBu': 'Diverging Red \u2192 Blue', - 'div-RdBu-inv': 'Diverging Blue \u2192 Red' + default: "Default", + "seq-Blues": "Sequential Blues", + "seq-BuGn": "Sequential Blue-Green", + "seq-GnBu": "Sequential Green-Blue", + "seq-Greens": "Sequential Greens", + "seq-YlOrRd": "Sequential Yellow-Orange-Red", + "seq-OrRd": "Sequential Orange-Red", + "seq-Reds": "Sequential Reds", + "seq-Heat": "Sequential Heat", + "seq-viridis": "Viridis (sequential)", + "psu-viridis": "PSU Viridis", + "div-Spectral": "Diverging Spectral", + "div-RdBu": "Diverging Red \u2192 Blue", + "div-RdBu-inv": "Diverging Blue \u2192 Red", }; export const FALLBACK_PALETTES = Object.keys(PALETTE_LABELS); export const DEFAULT_VARIABLE_LABELS = { - pr: 'Total Precipitation', - tas: 'Mean Temperature', - tasmax: 'Daily Maximum Temperature', - tasmin: 'Daily Minimum Temperature', - tmax: 'Maximum Temperature', - tmin: 'Minimum Temperature' + pr: "Total Precipitation", + tas: "Mean Temperature", + tasmax: "Daily Maximum Temperature", + tasmin: "Daily Minimum Temperature", + tmax: "Maximum Temperature", + tmin: "Minimum Temperature", }; export const CRS_OPTIONS = [ - { code: 'CRS:84', label: 'CRS:84' }, - { code: 'EPSG:4326', label: 'EPSG:4326' }, - { code: 'EPSG:3857', label: 'EPSG:3857' }, - { code: 'EPSG:3978', label: 'EPSG:3978' }, - { code: 'EPSG:3005', label: 'EPSG:3005' } + { code: "CRS:84", label: "CRS:84" }, + { code: "EPSG:4326", label: "EPSG:4326" }, + { code: "EPSG:3857", label: "EPSG:3857" }, + { code: "EPSG:3978", label: "EPSG:3978" }, + { code: "EPSG:3005", label: "EPSG:3005" }, ]; -export const DEFAULT_CANADA_BBOX_4326 = { west: -141, south: 41, east: -52, north: 84.5 }; +export const DEFAULT_CANADA_BBOX_4326 = { + west: -141, + south: 41, + east: -52, + north: 84.5, +}; export function normalizePortalId(value) { - return String(value || '').trim().toLowerCase(); + return String(value || "") + .trim() + .toLowerCase(); } export function isKnownPortalId(value) { @@ -71,17 +113,17 @@ export function buildDefaultPortalConfig(portalId) { id, title: known?.title || id, mount: known?.mount || id, - threddsRoot: '/pdp-next/thredds/', - defaultCrs: known?.defaultCrs || 'EPSG:3857', + threddsRoot: "/pdp-next/thredds/", + defaultCrs: known?.defaultCrs || "EPSG:3857", groups: [ { - id: 'default', - label: 'Datasets', + id: "default", + label: "Datasets", baseCatalogPath: `data/${known?.mount || id}`, - files: { excludeAnySubstr: ['/derived/', '/Derived/', 'derived/'] }, - variable: { fromFilename: { type: 'prefix', toLowerCase: true } } - } - ] + files: { excludeAnySubstr: ["/derived/", "/Derived/", "derived/"] }, + variable: { fromFilename: { type: "prefix", toLowerCase: true } }, + }, + ], }; } @@ -89,12 +131,100 @@ export function readPortalId() { const url = new URL(window.location.href); const raw = normalizePortalId(url.searchParams.get(PORTAL_PARAM_KEY)); if (isKnownPortalId(raw)) return raw; - const match = url.pathname.match(/\/portal\/([^/]+)\/?/i); + const match = url.pathname.match(/\/portal(?:=|\/)([^/]+)\/?$/i); const pathPortalId = normalizePortalId(match?.[1]); if (isKnownPortalId(pathPortalId)) return pathPortalId; return null; } +export function buildPortalUrl(portalId, href = window.location.href) { + const id = normalizePortalId(portalId); + const url = new URL(href); + url.pathname = url.pathname.replace(/\/portal(?:=|\/)[^/]+\/?$/i, "/"); + url.search = ""; + if (id) url.searchParams.set(PORTAL_PARAM_KEY, id); + return url.toString(); +} + +function finiteUrlNumber(value) { + if (value === null || value === "") return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +export function readViewerUrlState(href = window.location.href) { + const params = new URL(href).searchParams; + const rawView = String(params.get("view") || "") + .split(",") + .map(finiteUrlNumber); + const view = + rawView.length === 4 && + rawView.every((value) => value !== null) && + rawView[0] < rawView[2] && + rawView[1] < rawView[3] + ? rawView + : null; + const colors = finiteUrlNumber(params.get("colors")); + const opacity = finiteUrlNumber(params.get("opacity")); + + return { + dataset: String(params.get("dataset") || "").trim() || null, + variable: String(params.get("variable") || "").trim() || null, + view, + crs: + String(params.get("crs") || "") + .trim() + .toUpperCase() || null, + palette: String(params.get("palette") || "").trim() || null, + style: String(params.get("style") || "").trim() || null, + min: finiteUrlNumber(params.get("min")), + max: finiteUrlNumber(params.get("max")), + colors: colors === null ? null : Math.round(colors), + opacity: opacity === null ? null : Math.round(opacity), + time: String(params.get("time") || "").trim() || null, + }; +} + +function compactUrlNumber(value, precision = 6) { + if (value === null || value === undefined || value === "") return null; + const number = Number(value); + if (!Number.isFinite(number)) return null; + return String(Number(number.toFixed(precision))); +} + +export function buildViewerUrl( + portalId, + viewerState, + href = window.location.href, +) { + const url = new URL(buildPortalUrl(portalId, href)); + const setString = (key, value) => { + const text = String(value || "").trim(); + if (text) url.searchParams.set(key, text); + }; + setString("dataset", viewerState?.dataset); + setString("variable", viewerState?.variable); + if (Array.isArray(viewerState?.view) && viewerState.view.length === 4) { + const view = viewerState.view.map((value) => compactUrlNumber(value)); + if (view.every((value) => value !== null)) { + url.searchParams.set("view", view.join(",")); + } + } + setString("crs", viewerState?.crs); + setString("palette", viewerState?.palette); + setString("style", viewerState?.style); + const min = compactUrlNumber(viewerState?.min); + const max = compactUrlNumber(viewerState?.max); + const colors = compactUrlNumber(viewerState?.colors, 0); + const opacity = compactUrlNumber(viewerState?.opacity, 0); + if (min !== null) url.searchParams.set("min", min); + if (max !== null) url.searchParams.set("max", max); + if (colors !== null) url.searchParams.set("colors", colors); + if (opacity !== null) url.searchParams.set("opacity", opacity); + setString("time", viewerState?.time); + return url.toString(); +} + export function readDefaultPortalId() { const runtimeDefault = window.PDP_DEFAULT_PORTAL_ID; const candidate = normalizePortalId(runtimeDefault || DEFAULT_PORTAL_ID); diff --git a/viewer/js/core/dom.js b/viewer/js/core/dom.js index 98476b0..c1e8394 100644 --- a/viewer/js/core/dom.js +++ b/viewer/js/core/dom.js @@ -4,8 +4,13 @@ export const timeSlider = document.getElementById('timeSlider'); export const timeSliderContainer = document.getElementById('timeSliderContainer'); export const timeValue = document.getElementById('timeValue'); export const statusText = document.getElementById('statusText'); +const statusHistoryToggle = document.getElementById('statusHistoryToggle'); +const statusHistoryPanel = document.getElementById('statusHistoryPanel'); +const statusHistoryList = document.getElementById('statusHistoryList'); +const statusHistoryClear = document.getElementById('statusHistoryClear'); export const datasetName = document.getElementById('datasetName'); export const variableInfo = document.getElementById('variableInfo'); +export const selectionVariableIcon = document.getElementById('selectionVariableIcon'); export const timeInfo = document.getElementById('timeInfo'); export const metadataBtn = document.getElementById('metadataBtn'); export const paletteSelect = document.getElementById('paletteSelect'); @@ -36,12 +41,94 @@ export const subsetDownloadBtn = document.getElementById('subsetDownloadBtn'); const STATUS_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; const DEFAULT_READY_STATUS = 'Ready'; const STATUS_RESET_DELAY_MS = 4000; +const STATUS_HISTORY_LIMIT = 100; let statusSpinnerTimer = null; let statusSpinnerFrame = 0; let statusSpinnerStartedAt = 0; let statusSuppressed = false; let statusResetTimer = null; +let nextStatusHistoryId = 1; +const statusHistory = []; + +function historyMessage(message) { + return String(message ?? '') + .replace(/^[\u2800-\u28ff]\s+/u, '') + .replace(/\s+\(\d+s\)$/u, '') + .trim(); +} + +function historyTime(date) { + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +function renderStatusHistory() { + if (!statusHistoryList || statusHistoryPanel?.hidden) return; + statusHistoryList.replaceChildren(); + + if (!statusHistory.length) { + const empty = document.createElement('li'); + empty.className = 'status-history-empty'; + empty.textContent = 'No activity recorded.'; + statusHistoryList.append(empty); + return; + } + + statusHistory.forEach((entry) => { + const item = document.createElement('li'); + item.className = `status-history-item${entry.isError ? ' is-error' : ''}`; + + const time = document.createElement('time'); + time.className = 'status-history-time'; + time.dateTime = entry.at.toISOString(); + time.textContent = historyTime(entry.at); + + const message = document.createElement('span'); + message.className = 'status-history-message'; + message.textContent = entry.message; + + item.append(time, message); + statusHistoryList.append(item); + }); +} + +function recordStatus(message, isError) { + const normalizedMessage = historyMessage(message); + if (!normalizedMessage) return; + + const latest = statusHistory[0]; + if (latest?.message === normalizedMessage && latest.isError === isError) return; + + statusHistory.unshift({ + id: nextStatusHistoryId++, + message: normalizedMessage, + isError, + at: new Date() + }); + if (statusHistory.length > STATUS_HISTORY_LIMIT) statusHistory.length = STATUS_HISTORY_LIMIT; + renderStatusHistory(); +} + +function setStatusHistoryOpen(open) { + if (!statusHistoryToggle || !statusHistoryPanel) return; + statusHistoryPanel.hidden = !open; + statusHistoryToggle.setAttribute('aria-expanded', String(open)); + statusHistoryToggle.title = open ? 'Hide status history' : 'Show status history'; + if (open) renderStatusHistory(); +} + +statusHistoryToggle?.addEventListener('click', () => { + setStatusHistoryOpen(statusHistoryToggle.getAttribute('aria-expanded') !== 'true'); +}); + +statusHistoryClear?.addEventListener('click', () => { + statusHistory.length = 0; + renderStatusHistory(); +}); export function suppressStatusUpdates() { statusSuppressed = true; } export function unsuppressStatusUpdates() { statusSuppressed = false; } @@ -67,6 +154,7 @@ export function setStatus(message, isError = false) { clearStatusResetTimer(); statusText.textContent = message; statusText.style.color = isError ? '#d32f2f' : 'var(--text-muted)'; + recordStatus(message, isError); scheduleStatusReset(isError); } @@ -74,6 +162,7 @@ export function forceSetStatus(message, isError = false) { clearStatusResetTimer(); statusText.textContent = message; statusText.style.color = isError ? '#d32f2f' : 'var(--text-muted)'; + recordStatus(message, isError); scheduleStatusReset(isError); } @@ -96,3 +185,5 @@ export function stopStatusSpinner(message, isError = false) { } if (message) setStatus(message, isError); } + +recordStatus(DEFAULT_READY_STATUS, false); diff --git a/viewer/js/events.js b/viewer/js/events.js index 4a3b96b..a1cb95d 100644 --- a/viewer/js/events.js +++ b/viewer/js/events.js @@ -1,4 +1,4 @@ -import { WMS_VERSION, DEFAULT_CANADA_BBOX_4326 } from './core/config.js'; +import { buildPortalUrl } from './core/config.js'; import { timeModeBtns, timeSlider, @@ -19,7 +19,7 @@ import { export function wireEvents({ state, - requestedPortalId, + activePortalId, // time getSubsetTimeMode, getSelectedTimeIndex, @@ -36,11 +36,11 @@ export function wireEvents({ syncPaletteEnabled, setMapProjection, getCurrentCrs, - fitMapToBbox4326, // subset setSubsetDrawMode, clearSubsetDrawing, - downloadSubset + downloadSubset, + viewerStateChanged }) { let lastAppliedTimeSliderValue = null; @@ -60,6 +60,7 @@ export function wireEvents({ timeValue.textContent = getSelectedTimeLabel(); refreshInfoPanel(); updateMap(); + viewerStateChanged(); return true; } @@ -91,6 +92,7 @@ export function wireEvents({ timeValue.textContent = getSelectedTimeLabel(); refreshInfoPanel(); updateMap(); + viewerStateChanged(); }); timeSlider.addEventListener('change', () => { if (!hasMultipleTimes()) { @@ -103,27 +105,37 @@ export function wireEvents({ updateTimeUI(); refreshInfoPanel(); updateMap(); + viewerStateChanged(); }); opacitySlider.addEventListener('input', () => { setLayerOpacity(opacitySlider.value); + viewerStateChanged(); }); - applyScaleBtn.addEventListener('click', () => updateMap()); - styleSelect.addEventListener('change', () => { syncPaletteEnabled(); updateMap(); }); - paletteSelect.addEventListener('change', () => updateMap()); + applyScaleBtn.addEventListener('click', () => { + updateMap(); + viewerStateChanged(); + }); + styleSelect.addEventListener('change', () => { + syncPaletteEnabled(); + updateMap(); + viewerStateChanged(); + }); + paletteSelect.addEventListener('change', () => { + updateMap(); + viewerStateChanged(); + }); portalSelect.addEventListener('change', (e) => { const next = String(e.target.value || '').trim().toLowerCase(); - if (!next || next === requestedPortalId) return; - const url = new URL(window.location.href); - url.searchParams.set('portal', next); - window.location.href = url.toString(); + if (!next || next === activePortalId) return; + window.location.assign(buildPortalUrl(next)); }); metadataBtn.addEventListener('click', () => { if (!state.currentDataset) return alert('Please select a dataset first'); - window.open(`${state.currentDataset.wmsBase}?service=WMS&request=GetCapabilities&version=${encodeURIComponent(WMS_VERSION)}`, '_blank'); + window.open(state.currentDataset.ncmlUrl, '_blank', 'noopener'); }); crsSelect.addEventListener('change', () => { @@ -133,8 +145,8 @@ export function wireEvents({ crsSelect.value = getCurrentCrs(); return; } - fitMapToBbox4326(state.selectedLayer?.bbox4326 || DEFAULT_CANADA_BBOX_4326); updateMap(); + viewerStateChanged(); }); subsetTimeModeInputs.forEach((input) => { diff --git a/viewer/js/map/controller.js b/viewer/js/map/controller.js index 01161f4..56d98f4 100644 --- a/viewer/js/map/controller.js +++ b/viewer/js/map/controller.js @@ -111,6 +111,46 @@ export function createMapController({ map.addLayer(subsetDrawLayer); let wmsLayer = null; + function validExtent(extent) { + return ( + Array.isArray(extent) && + extent.length === 4 && + extent.every(Number.isFinite) && + extent[0] <= extent[2] && + extent[1] <= extent[3] + ); + } + + function captureViewExtent4326(projection) { + const size = map.getSize(); + if (!size) return null; + const extent = map.getView().calculateExtent(size); + if (!validExtent(extent)) return null; + const geographicExtent = olRef.proj.transformExtent( + extent, + projection, + "EPSG:4326", + 8, + ); + return validExtent(geographicExtent) ? geographicExtent : null; + } + + function reprojectSubsetFeatures(previousCrs, nextCrs) { + subsetDrawSource.getFeatures().forEach((feature) => { + const geometry = feature.getGeometry(); + if (!geometry) return; + let sourceGeometry = feature.get("selectionGeometry"); + let sourceCrs = feature.get("selectionCrs"); + if (!sourceGeometry?.clone || !sourceCrs) { + sourceGeometry = geometry.clone(); + sourceCrs = previousCrs; + feature.set("selectionGeometry", sourceGeometry.clone(), true); + feature.set("selectionCrs", sourceCrs, true); + } + feature.setGeometry(sourceGeometry.clone().transform(sourceCrs, nextCrs)); + }); + } + function setMapProjection(nextCrs) { const code = String(nextCrs || "") .trim() @@ -118,11 +158,8 @@ export function createMapController({ if (!olRef.proj.get(code)) return false; if (code === currentCrs) return true; const previousCrs = currentCrs; - subsetDrawSource.getFeatures().forEach((feature) => { // Reproject any existing subset drawing to the new CRS - const geometry = feature.getGeometry(); - if (!geometry) return; - geometry.transform(previousCrs, code); - }); + const previousViewExtent = captureViewExtent4326(previousCrs); + reprojectSubsetFeatures(previousCrs, code); currentCrs = code; const nextCenter = olRef.proj.transform( DEFAULT_VIEW_CENTER_LONLAT, @@ -135,6 +172,20 @@ export function createMapController({ zoom: 3, }); map.setView(mapView); + if (previousViewExtent) { + const projectedViewExtent = olRef.proj.transformExtent( + previousViewExtent, + "EPSG:4326", + currentCrs, + 8, + ); + if (validExtent(projectedViewExtent)) { + mapView.fit(projectedViewExtent, { + padding: [0, 0, 0, 0], + duration: 0, + }); + } + } return true; } @@ -301,12 +352,80 @@ export function createMapController({ legendPanel.classList.remove("hidden"); } - function updateInfoPanel(datasetName, variableInfo, timeInfo) { - datasetName.textContent = state.currentDataset?.name || "—"; - datasetName.title = state.currentDataset?.urlPath || ""; - variableInfo.textContent = state.variable - ? variableLabel(state.variable, state.group) + function variableIcon(variableName, displayLabel) { + const metadata = state.currentDataset?.metadata?.primary || {}; + const identity = [ + variableName, + displayLabel, + metadata.long_name, + metadata.standard_name, + ].filter(Boolean).join(" ").toLowerCase().replaceAll("_", " "); + if (/\b(tas|max(?:imum)? temperature|min(?:imum)? temperature|temperature)\b/.test(identity)) { + return { + kind: "temperature", + svg: '', + }; + } + if (/\b(snow|swe)\b/.test(identity)) { + return { + kind: "snow", + svg: '', + }; + } + if (/\bglac(?:ier)?\b/.test(identity)) { + return { + kind: "glacier", + svg: '', + }; + } + if (/\b(soil moisture|soil moist)\b/.test(identity)) { + return { + kind: "soil", + svg: '', + }; + } + if (/\b(evap|evaporation|evapotranspiration|transpiration|pet)\b/.test(identity)) { + return { + kind: "evaporation", + svg: '', + }; + } + if (/\b(baseflow|runoff|outflow|flow)\b/.test(identity)) { + return { + kind: "flow", + svg: '', + }; + } + if (isPrecipVariable(variableName) || /\b(precipitation|rainfall|rain)\b/.test(identity)) { + return { + kind: "precipitation", + svg: '', + }; + } + return { + kind: "generic", + svg: '', + }; + } + + function updateInfoPanel(datasetName, variableInfo, timeInfo, variableIconElement) { + const selectionLabel = state.currentDataset?.selectionLabel; + datasetName.textContent = selectionLabel || state.currentDataset?.name || "—"; + datasetName.title = [ + portal.title, + selectionLabel, + state.currentDataset?.urlPath, + ].filter(Boolean).join("\n"); + const displayVariable = state.variable + ? state.currentDataset?.variableLabel + || variableLabel(state.variable, state.group) : "—"; + variableInfo.textContent = displayVariable; + if (variableIconElement) { + const icon = variableIcon(state.variable, displayVariable); + variableIconElement.dataset.kind = icon.kind; + variableIconElement.innerHTML = icon.svg; + } timeInfo.textContent = getSelectedTimeLabel(); } @@ -464,6 +583,13 @@ export function createMapController({ return true; } + function getViewBbox4326() { + const extent = captureViewExtent4326(currentCrs); + if (!extent) return null; + const [west, south, east, north] = extent; + return { west, south, east, north }; + } + function populateCrsSelect(CRS_OPTIONS) { CRS_OPTIONS.forEach(({ code, label }) => { const opt = document.createElement("option"); @@ -491,6 +617,7 @@ export function createMapController({ updateMap, setLayerOpacity, fitMapToBbox4326, + getViewBbox4326, populateCrsSelect, }; } diff --git a/viewer/js/portal/datasets.js b/viewer/js/portal/datasets.js index 5ced8fc..29c7721 100644 --- a/viewer/js/portal/datasets.js +++ b/viewer/js/portal/datasets.js @@ -47,7 +47,12 @@ export function createDatasetController({ populatePaletteSelect, pickDefaultPaletteForVar, } = layer; - const { refreshInfoPanel, updateMap } = render; + const { + refreshInfoPanel, + updateMap, + applyInitialViewerState, + viewerStateChanged, + } = render; function threddsRoot() { const root = String(portal.threddsRoot || "/thredds/"); @@ -66,6 +71,10 @@ export function createDatasetController({ return `${threddsRoot()}dodsC/${urlPath}`; } + function ncmlUrlForUrlPath(urlPath) { + return `${threddsRoot()}ncml/${urlPath}`; + } + function ncpartitionerBase() { return "/pdp-next/ncpartitioner/"; } @@ -106,7 +115,7 @@ export function createDatasetController({ state.variable = state.selectedLayer?.name || state.variable; } - function syncCrsForLayer() { + function syncCrsForLayer({ fitToLayer = false } = {}) { if (olRef.proj.get(getCurrentCrs())) { crsSelect.value = getCurrentCrs(); } else { @@ -116,7 +125,11 @@ export function createDatasetController({ crsSelect.value = best; } } - fitMapToBbox4326(state.selectedLayer?.bbox4326 || DEFAULT_CANADA_BBOX_4326); + if (fitToLayer) { + fitMapToBbox4326( + state.selectedLayer?.bbox4326 || DEFAULT_CANADA_BBOX_4326, + ); + } } function applyTimesToUI() { @@ -208,21 +221,27 @@ export function createDatasetController({ async function loadDatasetFromUrlPath({ name, + selectionLabel = null, urlPath, variable, + variableLabel = null, metadata = null, rendering = null, timeMetadata = null, }) { try { + const isInitialDataset = !state.currentDataset; cancelPendingSubsetStatus?.(); stopStatusSpinner(); setStatus("Loading dataset…"); legendPanel?.classList.add("hidden"); state.currentDataset = { name, + selectionLabel, + variableLabel, urlPath, wmsBase: wmsBaseForUrlPath(urlPath), + ncmlUrl: ncmlUrlForUrlPath(urlPath), metadata, rendering, timeMetadata, @@ -235,7 +254,7 @@ export function createDatasetController({ state.metadataRange = null; await resolveLayersFromCapabilities(); - syncCrsForLayer(); + syncCrsForLayer({ fitToLayer: isInitialDataset }); initTimesFromLayer(timeMetadata); let details = null; @@ -254,8 +273,10 @@ export function createDatasetController({ overrideSingleTimeFromMetadata(timeMetadata); applyPaletteAndScale(details, rendering); + applyInitialViewerState?.(); refreshInfoPanel(); updateMap(); + viewerStateChanged?.(); setStatus("Ready"); } catch (err) { console.error(err); diff --git a/viewer/js/portal/menu.js b/viewer/js/portal/menu.js index 045b028..67883f0 100644 --- a/viewer/js/portal/menu.js +++ b/viewer/js/portal/menu.js @@ -1,4 +1,7 @@ -import { KNOWN_PORTALS, PORTAL_PARAM_KEY } from '../core/config.js'; +import { + DEFAULT_VARIABLE_LABELS, + KNOWN_PORTALS, +} from '../core/config.js'; function setActiveMenuItem(el) { document.querySelectorAll('.variable-item').forEach((i) => i.classList.remove('active')); @@ -17,6 +20,8 @@ function setActiveMenuItem(el) { export function createMenuController({ portal, + initialDatasetUrlPath, + initialVariable, ui, services, loadDatasetFromUrlPath @@ -66,11 +71,48 @@ export function createMenuController({ return index; } - function renderMenuFromPortalMeta(metaPayload) { + async function renderMenuFromPortalMeta(metaPayload) { datasetMenu.innerHTML = ''; const menuTree = metaPayload?.menu; if (!menuTree || typeof menuTree !== 'object') throw new Error('portal-meta is missing menu tree'); const basenameIndex = buildBasenameIndex(metaPayload); + const selectableItems = []; + + function variableMenuLabel(entry, fallbackLabel = '') { + const variableCode = String(entry?.metadata?.primary?.name || '').trim(); + const menuLabel = String(entry?.menuFields?.variable || fallbackLabel || variableCode).trim(); + if (menuLabel.toLowerCase() !== variableCode.toLowerCase()) return menuLabel; + return DEFAULT_VARIABLE_LABELS[variableCode] + || DEFAULT_VARIABLE_LABELS[variableCode.toLowerCase()] + || menuLabel; + } + + async function selectDataset(element, entry, basename, selectionLabel) { + setActiveMenuItem(element); + const isInitialUrlDataset = + entry.thredds.urlPath === initialDatasetUrlPath; + await loadDatasetFromUrlPath({ + name: entry.basename || basename, + selectionLabel, + urlPath: entry.thredds.urlPath, + variable: isInitialUrlDataset && initialVariable + ? initialVariable + : entry?.metadata?.primary?.name || null, + variableLabel: variableMenuLabel(entry), + metadata: entry?.metadata || null, + rendering: entry?.rendering || null, + timeMetadata: entry?.metadata?.time || null + }); + } + + function registerSelectable(element, entry, basename, selectionPath) { + const selectionLabel = selectionPath.slice(0, -1).join(' › ') + || selectionPath.join(' › '); + selectableItems.push({ element, entry, basename, selectionLabel }); + element.addEventListener('click', () => { + selectDataset(element, entry, basename, selectionLabel); + }); + } let defaultSelection = null; const topLabels = Object.keys(menuTree).sort((a, b) => a.localeCompare(b)); if (topLabels.length) { @@ -110,19 +152,9 @@ export function createMenuController({ if (!entry?.thredds?.urlPath) return; const fileLi = document.createElement('li'); fileLi.className = 'variable-item'; - fileLi.textContent = nodeLabel; + fileLi.textContent = variableMenuLabel(entry, nodeLabel); fileLi.title = entry.thredds.urlPath; - fileLi.addEventListener('click', () => { - setActiveMenuItem(fileLi); - loadDatasetFromUrlPath({ - name: entry.basename || basename, - urlPath: entry.thredds.urlPath, - variable: entry?.metadata?.primary?.name || null, - metadata: entry?.metadata || null, - rendering: entry?.rendering || null, - timeMetadata: entry?.metadata?.time || null - }); - }); + registerSelectable(fileLi, entry, basename, pathNow); containerUl.appendChild(fileLi); return; } @@ -131,7 +163,8 @@ export function createMenuController({ li.className = 'menu-item'; const header = document.createElement('div'); header.className = 'menu-header group-header'; - header.innerHTML = `${nodeLabel}`; + const firstEntry = basenameIndex.get(String(nodeValue[0] || '').trim()); + header.innerHTML = `${variableMenuLabel(firstEntry, nodeLabel)}`; li.appendChild(header); const children = document.createElement('ul'); children.className = 'menu-children'; @@ -145,17 +178,7 @@ export function createMenuController({ fileLi.className = 'variable-item'; fileLi.textContent = entry.basename || basename; fileLi.title = entry.thredds.urlPath; - fileLi.addEventListener('click', () => { - setActiveMenuItem(fileLi); - loadDatasetFromUrlPath({ - name: entry.basename || basename, - urlPath: entry.thredds.urlPath, - variable: entry?.metadata?.primary?.name || null, - metadata: entry?.metadata || null, - rendering: entry?.rendering || null, - timeMetadata: entry?.metadata?.time || null - }); - }); + registerSelectable(fileLi, entry, basename, pathNow); children.appendChild(fileLi); }); @@ -171,10 +194,16 @@ export function createMenuController({ } topLabels.forEach((label) => renderNode(label, menuTree[label], datasetMenu)); - const firstSelectable = datasetMenu.querySelector('.variable-item'); - if (firstSelectable) { - setActiveMenuItem(firstSelectable); - firstSelectable.dispatchEvent(new Event('click')); + const initialSelection = selectableItems.find( + ({ entry }) => entry.thredds.urlPath === initialDatasetUrlPath, + ) || selectableItems[0]; + if (initialSelection) { + await selectDataset( + initialSelection.element, + initialSelection.entry, + initialSelection.basename, + initialSelection.selectionLabel, + ); } } @@ -182,7 +211,7 @@ export function createMenuController({ datasetMenu.innerHTML = ''; setStatus('Loading portal metadata…'); const metaPayload = await loadPortalMeta(portal.id); - renderMenuFromPortalMeta(metaPayload); + await renderMenuFromPortalMeta(metaPayload); setStatus('Ready') } @@ -197,11 +226,6 @@ export function createMenuController({ portalSelect.appendChild(opt); }); portalSelect.value = portal.id; - portalSelect.addEventListener('change', () => { - const url = new URL(window.location.href); - url.searchParams.set(PORTAL_PARAM_KEY, portalSelect.value); - window.location.href = url.toString(); - }, { once: true }); } return { diff --git a/viewer/js/subsetting/download.js b/viewer/js/subsetting/download.js index c34dfae..1b76c40 100644 --- a/viewer/js/subsetting/download.js +++ b/viewer/js/subsetting/download.js @@ -44,7 +44,12 @@ export function createSubsetDownloadController({ }; // Thrown to short-circuit downloadSubset() - class SubsetCancelled extends Error {} + class SubsetCancelled extends Error { + constructor(message = '', isError = false) { + super(message); + this.isError = isError; + } + } let activeBackgroundStatus = null; let activeNcPollRunId = null; @@ -52,6 +57,45 @@ export function createSubsetDownloadController({ let activeFetchController = null; const ncpartitionerPublicRoot = new URL(ncpartitionerBase(), window.location.origin); + [subsetTimeStart, subsetTimeEnd].forEach((input) => { + input?.addEventListener('input', () => { + input.classList.remove('is-invalid', 'is-adjusted'); + input.removeAttribute('aria-invalid'); + }); + }); + + function dateInputValue(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value || '').slice(0, 10); + const pad = (part) => String(part).padStart(2, '0'); + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`; + } + + function flagTimeInputs(inputs, className = 'is-invalid') { + const uniqueInputs = [...new Set(inputs.filter(Boolean))]; + uniqueInputs.forEach((input) => { + input.classList.remove('subset-time-shake'); + input.getBoundingClientRect(); + input.classList.add(className, 'subset-time-shake'); + if (className === 'is-invalid') input.setAttribute('aria-invalid', 'true'); + input.addEventListener('animationend', () => input.classList.remove('subset-time-shake'), { once: true }); + }); + if (className === 'is-invalid') uniqueInputs[0]?.focus(); + } + + function cancelInvalidTimeRange(run, reason, message, inputs) { + flagTimeInputs(inputs); + alert(message); + logger.finishSubsetRun(run, 'cancelled', { reason }); + throw new SubsetCancelled(message, true); + } + + function cancelSubsetWithError(run, reason, message) { + alert(message); + logger.finishSubsetRun(run, 'cancelled', { reason }); + throw new SubsetCancelled(message, true); + } + function setSubsetDownloadBusy(isBusy) { subsetDownloadBtn.disabled = isBusy; subsetDownloadBtn.textContent = isBusy ? `${SUBSET_DOWNLOAD_LABEL}...` : SUBSET_DOWNLOAD_LABEL; @@ -189,6 +233,32 @@ export function createSubsetDownloadController({ && outer.north >= (inner.north - tolerance); } + function bboxIntersection(first, second) { + if (!first || !second) return null; + const intersection = { + west: Math.max(first.west, second.west), + south: Math.max(first.south, second.south), + east: Math.min(first.east, second.east), + north: Math.min(first.north, second.north) + }; + if (![intersection.west, intersection.south, intersection.east, intersection.north].every(Number.isFinite)) return null; + return intersection.west <= intersection.east && intersection.south <= intersection.north + ? intersection + : null; + } + + function intersectSpatialSelection(bbox, datasetBbox, run) { + const intersection = bboxIntersection(bbox, datasetBbox); + if (!intersection) { + cancelSubsetWithError( + run, + 'bbox-outside-dataset', + 'The selected area does not overlap this dataset. No download was started.' + ); + } + return intersection; + } + /** * Resolves the spatial extent for the subset based on the UI's spatial mode. * Returns { bbox, useWholeSpatialDomain }. @@ -196,7 +266,8 @@ export function createSubsetDownloadController({ * 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 }; + const advertisedBbox = state.selectedLayer?.bbox4326 || null; + const datasetBbox = advertisedBbox || { west: -180, south: -90, east: 180, north: 90 }; if (spatialMode === 'whole') { return { bbox: datasetBbox, useWholeSpatialDomain: true }; @@ -209,7 +280,10 @@ export function createSubsetDownloadController({ logger.finishSubsetRun(run, 'cancelled', { reason: 'missing-drawn-bbox' }); throw new SubsetCancelled(); } - return { bbox, useWholeSpatialDomain: false }; + return { + bbox: advertisedBbox ? intersectSpatialSelection(bbox, advertisedBbox, run) : bbox, + useWholeSpatialDomain: false + }; } const bbox = drawController.getCurrentViewBbox4326(); @@ -219,7 +293,12 @@ export function createSubsetDownloadController({ throw new SubsetCancelled(); } const useWholeSpatialDomain = bboxContains(bbox, datasetBbox); - return { bbox: useWholeSpatialDomain ? datasetBbox : bbox, useWholeSpatialDomain }; + return { + bbox: useWholeSpatialDomain + ? datasetBbox + : (advertisedBbox ? intersectSpatialSelection(bbox, advertisedBbox, run) : bbox), + useWholeSpatialDomain + }; } /** @@ -251,21 +330,66 @@ export function createSubsetDownloadController({ 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 invalidInputs = [ + ...(startIso === null ? [subsetTimeStart] : []), + ...(endIso === null ? [subsetTimeEnd] : []) + ]; + if (invalidInputs.length) { + cancelInvalidTimeRange( + run, + 'invalid-date-input', + 'Please enter dates as YYYY, YYYY-MM, YYYY-MM-DD (or with / separators).', + invalidInputs + ); } 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(); + cancelInvalidTimeRange( + run, + 'invalid-date-range', + 'Start date must be before end date.', + [subsetTimeStart, subsetTimeEnd] + ); } const fullRangeStart = state.selectedLayer?.time?.start || state.times?.[0] || ''; const fullRangeEnd = state.selectedLayer?.time?.end || state.times?.[state.times.length - 1] || fullRangeStart; + const availableStartMs = Date.parse(fullRangeStart); + const availableEndMs = Date.parse(fullRangeEnd); + const requestedStartMs = Date.parse(rangeStart || fullRangeStart); + const requestedEndMs = Date.parse(rangeEnd || fullRangeEnd); + + if ( + Number.isFinite(availableStartMs) + && Number.isFinite(availableEndMs) + && Number.isFinite(requestedStartMs) + && Number.isFinite(requestedEndMs) + ) { + const availableLabel = `${dateInputValue(fullRangeStart)} to ${dateInputValue(fullRangeEnd)}`; + const availableStartDate = dateInputValue(fullRangeStart); + const availableEndDate = dateInputValue(fullRangeEnd); + const requestedStartDate = dateInputValue(rangeStart || fullRangeStart); + const requestedEndDate = dateInputValue(rangeEnd || fullRangeEnd); + + const startOutOfRange = requestedStartDate < availableStartDate || requestedStartDate > availableEndDate; + const endOutOfRange = rangeEnd + ? (requestedEndDate < availableStartDate || requestedEndDate > availableEndDate) + : false; + if (startOutOfRange || endOutOfRange) { + const outOfRangeInputs = [ + ...(startOutOfRange ? [subsetTimeStart] : []), + ...(endOutOfRange ? [subsetTimeEnd] : []) + ]; + cancelInvalidTimeRange( + run, + 'date-range-outside-dataset', + `Start and end dates must be within this dataset's available range (${availableLabel}). No download was started.`, + outOfRangeInputs + ); + } + } + if (state.times.length) { const [timeStartIndex, timeEndIndex] = indexController.findTimeIndexRange(state.times, rangeStart || fullRangeStart, rangeEnd || fullRangeEnd); if (timeStartIndex === 0 && timeEndIndex === (state.times.length - 1)) { @@ -543,7 +667,10 @@ export function createSubsetDownloadController({ }); } catch (error) { if (error instanceof SubsetCancelled || error?.name === 'AbortError') { - cancelPendingSubsetStatus(error instanceof SubsetCancelled ? error.message : ''); + cancelPendingSubsetStatus( + error instanceof SubsetCancelled ? error.message : '', + error instanceof SubsetCancelled && error.isError + ); return; } activeNcPollRunId = null; @@ -568,4 +695,4 @@ export function createSubsetDownloadController({ downloadSubset, cancelPendingSubsetStatus }; -} +} \ No newline at end of file diff --git a/viewer/js/subsetting/draw.js b/viewer/js/subsetting/draw.js index 65c618c..195e86b 100644 --- a/viewer/js/subsetting/draw.js +++ b/viewer/js/subsetting/draw.js @@ -7,6 +7,37 @@ export function createSubsetDrawController({ getCurrentCrs }) { let subsetDrawInteraction = null; + const EDGE_SEGMENTS = 16; + + function densifyPolygon(geometry) { + if (geometry.getType() !== 'Polygon') return geometry.clone(); + const rings = geometry.getCoordinates().map((ring) => { + const denseRing = []; + for (let index = 0; index < ring.length - 1; index += 1) { + const start = ring[index]; + const end = ring[index + 1]; + for (let step = 0; step < EDGE_SEGMENTS; step += 1) { + const fraction = step / EDGE_SEGMENTS; + denseRing.push([ + start[0] + ((end[0] - start[0]) * fraction), + start[1] + ((end[1] - start[1]) * fraction) + ]); + } + } + denseRing.push([...ring[ring.length - 1]]); + return denseRing; + }); + return new olRef.geom.Polygon(rings); + } + + function rememberOriginalGeometry(feature) { + const geometry = feature?.getGeometry(); + if (!geometry) return; + const originalGeometry = densifyPolygon(geometry); + feature.setGeometry(originalGeometry); + feature.set('selectionGeometry', originalGeometry.clone(), true); + feature.set('selectionCrs', getCurrentCrs(), true); + } function clearSubsetDrawing() { subsetDrawSource.clear(); @@ -27,7 +58,10 @@ export function createSubsetDrawController({ geometryFunction: olRef.interaction.Draw.createBox() }); subsetDrawInteraction.on('drawstart', () => clearSubsetDrawing()); - subsetDrawInteraction.on('drawend', () => setStatus('Drawing captured for subset.')); + subsetDrawInteraction.on('drawend', (event) => { + rememberOriginalGeometry(event.feature); + setStatus('Drawing captured for subset.'); + }); map.addInteraction(subsetDrawInteraction); } @@ -45,8 +79,16 @@ export function createSubsetDrawController({ if (!feature) return null; const geometry = feature.getGeometry(); if (!geometry) return null; - const extent = geometry.getExtent(); - const ll = olRef.proj.transformExtent(extent, getCurrentCrs(), 'EPSG:4326'); + const originalGeometry = feature.get('selectionGeometry'); + const originalCrs = feature.get('selectionCrs'); + const ll = originalGeometry?.clone && originalCrs + ? originalGeometry.clone().transform(originalCrs, 'EPSG:4326').getExtent() + : olRef.proj.transformExtent( + geometry.getExtent(), + getCurrentCrs(), + 'EPSG:4326', + 8 + ); const [west, south, east, north] = ll; return { west, south, east, north }; } diff --git a/viewer/js/time/parse.js b/viewer/js/time/parse.js index fb8e53b..a7e7bca 100644 --- a/viewer/js/time/parse.js +++ b/viewer/js/time/parse.js @@ -206,6 +206,7 @@ export function createTimeParseHelpers({ state, TIME_EXPAND_LIMIT }) { const text = String(value || '').trim(); if (!text) return ''; const normalized = text.replace(/\//g, '-'); + const yearOnly = normalized.match(/^(\d{4})$/); if (yearOnly) { const year = Number(yearOnly[1]); @@ -232,9 +233,7 @@ export function createTimeParseHelpers({ state, TIME_EXPAND_LIMIT }) { ? new Date(Date.UTC(year, monthIndex, day, 23, 59, 59, 999)).toISOString() : new Date(Date.UTC(year, monthIndex, day, 0, 0, 0, 0)).toISOString(); } - const dt = new Date(normalized); - if (Number.isNaN(dt.getTime())) return null; - return dt.toISOString(); + return null; } return { @@ -248,4 +247,4 @@ export function createTimeParseHelpers({ state, TIME_EXPAND_LIMIT }) { toDateInputValue, parseSubsetDateValue }; -} \ No newline at end of file +} diff --git a/viewer/pdp-next-viewer.html b/viewer/pdp-next-viewer.html index 4df0a69..2ca11cb 100644 --- a/viewer/pdp-next-viewer.html +++ b/viewer/pdp-next-viewer.html @@ -17,7 +17,26 @@

PCIC Data Portal

- +
+
+
+ Selection + +
+
+ Variable + + + + +
+
+ Time + +
+ +
+
@@ -44,7 +63,17 @@

Climate Layer Opacity

- Ready + +
@@ -171,25 +200,6 @@

Subsetting

-
-
- Dataset - -
- -
- Variable - -
- -
- Time - -
- -
- -
diff --git a/viewer/styles/base.css b/viewer/styles/base.css index cc7efe5..356b174 100644 --- a/viewer/styles/base.css +++ b/viewer/styles/base.css @@ -11,6 +11,13 @@ body { font-family: var(--sans); } +button, +input, +select, +textarea { + font-family: inherit; +} + a { color: var(--pcic-blue); text-decoration: none; diff --git a/viewer/styles/controls.css b/viewer/styles/controls.css index 0afcfb4..15047e8 100644 --- a/viewer/styles/controls.css +++ b/viewer/styles/controls.css @@ -83,6 +83,38 @@ flex: 1; } +.color-row input.subset-time-shake { + animation: subset-time-shake 0.4s ease-in-out; +} + +.color-row input.is-invalid { + border-color: #d32f2f; + box-shadow: 0 0 0 1px rgba(211, 47, 47, 0.15); +} + +@keyframes subset-time-shake { + 0%, + 100% { + transform: translateX(0); + } + + 20%, + 60% { + transform: translateX(-5px); + } + + 40%, + 80% { + transform: translateX(5px); + } +} + +@media (prefers-reduced-motion: reduce) { + .color-row input.subset-time-shake { + animation: none; + } +} + .palette-preview { height: 20px; margin-top: 5px; diff --git a/viewer/styles/layout.css b/viewer/styles/layout.css index 33e693e..95781eb 100644 --- a/viewer/styles/layout.css +++ b/viewer/styles/layout.css @@ -1,35 +1,155 @@ .header-top { - display: flex; + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; align-items: center; - justify-content: space-between; - gap: 16px; + column-gap: 20px; + row-gap: 4px; } -.logo-section, -.header-links { +.logo-section { display: flex; + grid-column: 1; + grid-row: 1 / -1; align-items: center; -} - -.logo-section { gap: 20px; } -.header-links { - gap: 15px; - font-size: 14px; -} - .logo { height: 50px; } +.header-context { + width: 100%; + min-width: 0; + grid-column: 2; + grid-row: 2; +} + .page-title { + grid-column: 2; + grid-row: 1; margin: 0; color: var(--pcic-light-blue); font-size: 24px; font-weight: 300; - text-align: right; + line-height: 1.2; + text-align: center; +} + +.selection-summary { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: fit-content(460px) max-content max-content max-content; + align-items: center; + justify-content: center; + column-gap: clamp(8px, 1.25vw, 24px); +} + +.selection-summary-item { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.selection-summary-primary { + width: 100%; + min-width: 260px; + max-width: 460px; +} + +.selection-summary-label { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.selection-summary-value { + min-width: 0; + color: var(--text-dark); + font-size: 12px; + font-weight: 600; + line-height: 1.25; +} + +.selection-summary-primary .selection-summary-value { + overflow-wrap: anywhere; +} + +.selection-summary-variable, +.selection-summary-time, +.selection-variable-value, +.selection-summary-variable .selection-summary-value, +.selection-summary-time .selection-summary-value { + white-space: nowrap; +} + +.selection-variable-value { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.selection-variable-icon { + display: grid; + width: 22px; + height: 22px; + flex: 0 0 22px; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, currentColor 12%, transparent); + color: var(--pcic-blue); +} + +.selection-variable-icon[data-kind="precipitation"] { + color: #1976a8; +} + +.selection-variable-icon[data-kind="temperature"] { + color: #c94a38; +} + +.selection-variable-icon[data-kind="snow"] { + color: #5b84b1; +} + +.selection-variable-icon[data-kind="flow"] { + color: #39796b; +} + +.selection-variable-icon[data-kind="evaporation"] { + color: #4f7f47; +} + +.selection-variable-icon[data-kind="glacier"] { + color: #477f9d; +} + +.selection-variable-icon[data-kind="soil"] { + color: #8a6541; +} + +.selection-variable-icon svg { + width: 14px; + height: 14px; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.btn.selection-metadata-btn { + flex: 0 0 auto; + align-self: center; + padding: 5px 12px; + font-size: 11px; + white-space: nowrap; } .content-wrapper { @@ -162,7 +282,7 @@ bottom: 60px; max-width: min(460px, calc(100% - 40px)); min-width: 220px; - padding: 12px 16px; + overflow: hidden; border-radius: var(--radius-sm); box-shadow: var(--shadow-status); color: var(--text-muted); @@ -171,3 +291,112 @@ line-height: 1.45; white-space: normal; } + +.status-summary { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.status-summary:hover { + background: var(--bg-hover); +} + +.status-summary:focus-visible { + outline: 2px solid var(--pcic-accent); + outline-offset: -2px; +} + +.status-history-chevron { + width: 8px; + height: 8px; + flex: 0 0 8px; + transform: rotate(45deg); + border-top: 2px solid currentColor; + border-left: 2px solid currentColor; + transition: transform 0.15s ease; +} + +.status-summary[aria-expanded="true"] .status-history-chevron { + transform: rotate(225deg); +} + +.status-history-panel { + border-top: 1px solid var(--border-color); + background: var(--bg-panel); +} + +.status-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 6px; + color: var(--text-dark); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.status-history-clear { + padding: 2px 5px; + border: 0; + background: transparent; + color: var(--pcic-blue); + font: inherit; + text-transform: none; + cursor: pointer; +} + +.status-history-clear:hover { + text-decoration: underline; +} + +.status-history-list { + max-height: min(260px, 45vh); + margin: 0; + padding: 0 12px 10px; + overflow-y: auto; + list-style: none; +} + +.status-history-item { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + gap: 8px; + padding: 6px 0; + border-top: 1px solid var(--border-color); + font-size: 12px; + font-weight: 400; +} + +.status-history-time { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.status-history-message { + min-width: 0; + overflow-wrap: anywhere; + color: var(--text-dark); +} + +.status-history-item.is-error .status-history-message { + color: #d32f2f; + font-weight: 600; +} + +.status-history-empty { + padding: 8px 0; + color: var(--text-muted); + font-size: 12px; + font-style: italic; +} diff --git a/viewer/styles/responsive.css b/viewer/styles/responsive.css index f556a6c..28df63c 100644 --- a/viewer/styles/responsive.css +++ b/viewer/styles/responsive.css @@ -1,14 +1,33 @@ -@media (max-width: 1100px) { +@media (max-width: 1350px) { :root { --panel-width: 340px; } .header-top { - gap: 12px; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + row-gap: 4px; + } + + .logo-section { + grid-column: 1; + grid-row: 1; } .page-title { + position: static; + grid-column: 2; + grid-row: 1; + transform: none; font-size: 20px; + text-align: center; + } + + .header-context { + width: 100%; + grid-column: 1 / -1; + grid-row: 2; + padding-top: 0; } } @@ -30,14 +49,13 @@ border-top: 1px solid var(--border-color); } - .header-top, footer { + display: flex; flex-wrap: wrap; } .logo-section { min-width: 0; - width: 100%; } .logo { @@ -45,9 +63,38 @@ height: auto; } - .page-title { + .selection-summary { + display: grid; width: 100%; - text-align: left; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: repeat(3, auto); + justify-content: flex-start; + column-gap: 24px; + row-gap: 8px; + } + + .selection-summary-primary, + .selection-summary-variable, + .selection-summary-time { + grid-column: 1; + } + + .selection-summary-primary { + grid-row: 1; + } + + .selection-summary-variable { + grid-row: 2; + } + + .selection-summary-time { + grid-row: 3; + } + + .btn.selection-metadata-btn { + align-self: center; + grid-column: 2; + grid-row: 1 / 4; } .color-row { @@ -59,3 +106,42 @@ flex: none; } } + +@media (max-width: 720px) { + .header-top { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + justify-items: center; + } + + .logo-section { + width: 100%; + grid-column: 1; + grid-row: 1; + justify-content: center; + } + + .page-title { + grid-column: 1; + grid-row: 2; + } + + .header-context { + grid-column: 1; + grid-row: 3; + } +} + +@media (max-width: 520px) { + .selection-summary { + grid-template-columns: 1fr; + grid-template-rows: repeat(4, auto); + } + + .btn.selection-metadata-btn { + grid-column: 1; + grid-row: 4; + justify-self: start; + margin-top: 6px; + } +}