From fb1875bf65ed396cb58c8f0b76af7356afd089d6 Mon Sep 17 00:00:00 2001 From: Quintin Date: Fri, 24 Jul 2026 14:35:39 -0700 Subject: [PATCH 1/9] WIP: Address feedback --- .gitignore | 14 ++- README.md | 22 ++++ package.json | 3 +- scripts/dev-viewer.mjs | 146 +++++++++++++++++++++++ viewer/js/app.js | 119 ++++++++++++++++++- viewer/js/core/config.js | 224 +++++++++++++++++++++++++++-------- viewer/js/core/dom.js | 1 + viewer/js/events.js | 36 ++++-- viewer/js/map/controller.js | 144 ++++++++++++++++++++-- viewer/js/portal/datasets.js | 22 +++- viewer/js/portal/menu.js | 78 ++++++------ viewer/js/subsetting/draw.js | 48 +++++++- viewer/pdp-next-viewer.html | 40 +++---- viewer/styles/layout.css | 123 +++++++++++++++++-- viewer/styles/responsive.css | 61 +++++++++- 15 files changed, 929 insertions(+), 152 deletions(-) create mode 100644 scripts/dev-viewer.mjs 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/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..6443530 100644 --- a/viewer/js/core/dom.js +++ b/viewer/js/core/dom.js @@ -6,6 +6,7 @@ export const timeValue = document.getElementById('timeValue'); export const statusText = document.getElementById('statusText'); 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'); diff --git a/viewer/js/events.js b/viewer/js/events.js index 4a3b96b..4babeb7 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 { WMS_VERSION, 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,22 +105,32 @@ 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', () => { @@ -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..8ac6235 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,79 @@ 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 + 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 datasetParts = [portal.title, state.currentDataset?.selectionLabel] + .filter(Boolean); + datasetName.textContent = datasetParts.join(" · ") || "—"; + datasetName.title = [ + ...datasetParts, + state.currentDataset?.urlPath, + ].filter(Boolean).join("\n"); + const displayVariable = state.variable ? 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 +582,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 +616,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..1a33d21 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/"); @@ -106,7 +111,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 +121,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,6 +217,7 @@ export function createDatasetController({ async function loadDatasetFromUrlPath({ name, + selectionLabel = null, urlPath, variable, metadata = null, @@ -215,12 +225,14 @@ export function createDatasetController({ timeMetadata = null, }) { try { + const isInitialDataset = !state.currentDataset; cancelPendingSubsetStatus?.(); stopStatusSpinner(); setStatus("Loading dataset…"); legendPanel?.classList.add("hidden"); state.currentDataset = { name, + selectionLabel, urlPath, wmsBase: wmsBaseForUrlPath(urlPath), metadata, @@ -235,7 +247,7 @@ export function createDatasetController({ state.metadataRange = null; await resolveLayersFromCapabilities(); - syncCrsForLayer(); + syncCrsForLayer({ fitToLayer: isInitialDataset }); initTimesFromLayer(timeMetadata); let details = null; @@ -254,8 +266,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..6d8196b 100644 --- a/viewer/js/portal/menu.js +++ b/viewer/js/portal/menu.js @@ -1,4 +1,4 @@ -import { KNOWN_PORTALS, PORTAL_PARAM_KEY } from '../core/config.js'; +import { KNOWN_PORTALS } from '../core/config.js'; function setActiveMenuItem(el) { document.querySelectorAll('.variable-item').forEach((i) => i.classList.remove('active')); @@ -17,6 +17,8 @@ function setActiveMenuItem(el) { export function createMenuController({ portal, + initialDatasetUrlPath, + initialVariable, ui, services, loadDatasetFromUrlPath @@ -66,11 +68,38 @@ 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 = []; + + 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, + 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) { @@ -112,17 +141,7 @@ export function createMenuController({ fileLi.className = 'variable-item'; fileLi.textContent = 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; } @@ -145,17 +164,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 +180,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 +197,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 +212,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/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/pdp-next-viewer.html b/viewer/pdp-next-viewer.html index 4df0a69..320f0f1 100644 --- a/viewer/pdp-next-viewer.html +++ b/viewer/pdp-next-viewer.html @@ -17,7 +17,26 @@

PCIC Data Portal

- +
+
+
+ Selection + +
+
+ Variable + + + + +
+
+ Time + +
+ +
+
@@ -171,25 +190,6 @@

Subsetting

-
-
- Dataset - -
- -
- Variable - -
- -
- Time - -
- -
- -
diff --git a/viewer/styles/layout.css b/viewer/styles/layout.css index 33e693e..bce80c3 100644 --- a/viewer/styles/layout.css +++ b/viewer/styles/layout.css @@ -1,35 +1,132 @@ .header-top { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - justify-content: space-between; gap: 16px; } -.logo-section, -.header-links { +.logo-section { display: flex; align-items: center; -} - -.logo-section { gap: 20px; } -.header-links { - gap: 15px; - font-size: 14px; -} - .logo { height: 50px; } +.header-context { + min-width: 0; + justify-self: end; +} + .page-title { + grid-column: 2; margin: 0; color: var(--pcic-light-blue); font-size: 24px; font-weight: 300; - text-align: right; + text-align: center; +} + +.selection-summary { + display: grid; + min-width: 0; + grid-template-columns: minmax(260px, 420px) minmax(150px, auto) auto auto; + align-items: center; + justify-content: flex-end; + gap: 16px; +} + +.selection-summary-item { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.selection-summary-label { + color: var(--text-muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.selection-summary-value { + max-width: 220px; + overflow: hidden; + color: var(--text-dark); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selection-summary-primary .selection-summary-value { + max-width: 100%; +} + +.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; +} + +.selection-metadata-btn { + flex: 0 0 auto; + align-self: center; + padding: 5px 9px; + font-size: 11px; } .content-wrapper { diff --git a/viewer/styles/responsive.css b/viewer/styles/responsive.css index f556a6c..e213681 100644 --- a/viewer/styles/responsive.css +++ b/viewer/styles/responsive.css @@ -1,12 +1,30 @@ +@media (max-width: 1350px) { + .header-top { + gap: 12px; + } + + .header-context { + width: 100%; + grid-column: 1 / -1; + grid-row: 2; + } + + .selection-summary { + grid-template-columns: minmax(220px, 1fr) minmax(140px, auto) auto auto; + justify-content: end; + gap: 10px; + } + + .selection-summary-item:not(.selection-summary-primary) .selection-summary-value { + max-width: 150px; + } +} + @media (max-width: 1100px) { :root { --panel-width: 340px; } - .header-top { - gap: 12px; - } - .page-title { font-size: 20px; } @@ -32,6 +50,7 @@ .header-top, footer { + display: flex; flex-wrap: wrap; } @@ -45,11 +64,35 @@ height: auto; } + .header-context, .page-title { width: 100%; + align-items: flex-start; text-align: left; } + .header-context { + order: 3; + } + + .selection-summary { + width: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: flex-start; + } + + .selection-summary-primary { + grid-column: 1 / -1; + } + + .selection-summary-value { + max-width: min(240px, 60vw); + } + + .selection-metadata-btn { + justify-self: start; + } + .color-row { flex-direction: column; align-items: stretch; @@ -59,3 +102,13 @@ flex: none; } } + +@media (max-width: 520px) { + .selection-summary { + grid-template-columns: 1fr; + } + + .selection-summary-primary { + grid-column: auto; + } +} From 0068622a5504ab40716dd634dec488b122e0f6d8 Mon Sep 17 00:00:00 2001 From: Quintin Date: Mon, 27 Jul 2026 08:24:46 -0700 Subject: [PATCH 2/9] Link file metadata --- thredds/thredds-config/threddsConfig.xml | 6 ++++++ viewer/js/events.js | 4 ++-- viewer/js/portal/datasets.js | 5 +++++ viewer/pdp-next-viewer.html | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/thredds/thredds-config/threddsConfig.xml b/thredds/thredds-config/threddsConfig.xml index e42e6e4..23cb98b 100644 --- a/thredds/thredds-config/threddsConfig.xml +++ b/thredds/thredds-config/threddsConfig.xml @@ -13,6 +13,12 @@ true + + + true + false + false + true diff --git a/viewer/js/events.js b/viewer/js/events.js index 4babeb7..a1cb95d 100644 --- a/viewer/js/events.js +++ b/viewer/js/events.js @@ -1,4 +1,4 @@ -import { WMS_VERSION, buildPortalUrl } from './core/config.js'; +import { buildPortalUrl } from './core/config.js'; import { timeModeBtns, timeSlider, @@ -135,7 +135,7 @@ export function wireEvents({ 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', () => { diff --git a/viewer/js/portal/datasets.js b/viewer/js/portal/datasets.js index 1a33d21..749a11b 100644 --- a/viewer/js/portal/datasets.js +++ b/viewer/js/portal/datasets.js @@ -71,6 +71,10 @@ export function createDatasetController({ return `${threddsRoot()}dodsC/${urlPath}`; } + function ncmlUrlForUrlPath(urlPath) { + return `${threddsRoot()}ncml/${urlPath}`; + } + function ncpartitionerBase() { return "/pdp-next/ncpartitioner/"; } @@ -235,6 +239,7 @@ export function createDatasetController({ selectionLabel, urlPath, wmsBase: wmsBaseForUrlPath(urlPath), + ncmlUrl: ncmlUrlForUrlPath(urlPath), metadata, rendering, timeMetadata, diff --git a/viewer/pdp-next-viewer.html b/viewer/pdp-next-viewer.html index 320f0f1..0f83423 100644 --- a/viewer/pdp-next-viewer.html +++ b/viewer/pdp-next-viewer.html @@ -34,7 +34,7 @@

PCIC Data Portal

Time - + From f1ef150fa1bd9cc911d9e679206463357c07acb2 Mon Sep 17 00:00:00 2001 From: Quintin Date: Mon, 27 Jul 2026 08:56:45 -0700 Subject: [PATCH 3/9] Fix header css --- viewer/js/map/controller.js | 8 +-- viewer/pdp-next-viewer.html | 4 +- viewer/styles/layout.css | 48 +++++++++------ viewer/styles/responsive.css | 113 ++++++++++++++++++++++------------- 4 files changed, 109 insertions(+), 64 deletions(-) diff --git a/viewer/js/map/controller.js b/viewer/js/map/controller.js index 8ac6235..403a4ce 100644 --- a/viewer/js/map/controller.js +++ b/viewer/js/map/controller.js @@ -409,11 +409,11 @@ export function createMapController({ } function updateInfoPanel(datasetName, variableInfo, timeInfo, variableIconElement) { - const datasetParts = [portal.title, state.currentDataset?.selectionLabel] - .filter(Boolean); - datasetName.textContent = datasetParts.join(" · ") || "—"; + const selectionLabel = state.currentDataset?.selectionLabel; + datasetName.textContent = selectionLabel || state.currentDataset?.name || "—"; datasetName.title = [ - ...datasetParts, + portal.title, + selectionLabel, state.currentDataset?.urlPath, ].filter(Boolean).join("\n"); const displayVariable = state.variable diff --git a/viewer/pdp-next-viewer.html b/viewer/pdp-next-viewer.html index 0f83423..3c1a069 100644 --- a/viewer/pdp-next-viewer.html +++ b/viewer/pdp-next-viewer.html @@ -23,14 +23,14 @@

PCIC Data Portal

Selection -
+
Variable
-
+
Time
diff --git a/viewer/styles/layout.css b/viewer/styles/layout.css index bce80c3..fb59979 100644 --- a/viewer/styles/layout.css +++ b/viewer/styles/layout.css @@ -1,12 +1,16 @@ .header-top { + position: relative; display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto; align-items: center; - gap: 16px; + column-gap: 20px; } .logo-section { display: flex; + grid-column: 1; + grid-row: 1; align-items: center; gap: 20px; } @@ -16,13 +20,19 @@ } .header-context { + width: 100%; min-width: 0; - justify-self: end; + grid-column: 2; + grid-row: 1; + padding-top: 28px; } .page-title { - grid-column: 2; + position: absolute; + top: 0; + left: 50%; margin: 0; + transform: translateX(-50%); color: var(--pcic-light-blue); font-size: 24px; font-weight: 300; @@ -30,23 +40,27 @@ } .selection-summary { - display: grid; + display: flex; min-width: 0; - grid-template-columns: minmax(260px, 420px) minmax(150px, auto) auto auto; align-items: center; - justify-content: flex-end; - gap: 16px; + justify-content: center; + gap: 24px; } .selection-summary-item { display: flex; min-width: 0; - flex-direction: column; - align-items: flex-start; - gap: 2px; + align-items: center; + gap: 6px; +} + +.selection-summary-primary { + max-width: 460px; + flex: 0 1 auto; } .selection-summary-label { + flex: 0 0 62px; color: var(--text-muted); font-size: 10px; font-weight: 600; @@ -55,7 +69,8 @@ } .selection-summary-value { - max-width: 220px; + min-width: 0; + max-width: 100%; overflow: hidden; color: var(--text-dark); font-size: 12px; @@ -64,10 +79,6 @@ white-space: nowrap; } -.selection-summary-primary .selection-summary-value { - max-width: 100%; -} - .selection-variable-value { display: flex; min-width: 0; @@ -122,11 +133,12 @@ stroke-width: 1.8; } -.selection-metadata-btn { +.btn.selection-metadata-btn { flex: 0 0 auto; align-self: center; - padding: 5px 9px; + padding: 5px 12px; font-size: 11px; + white-space: nowrap; } .content-wrapper { diff --git a/viewer/styles/responsive.css b/viewer/styles/responsive.css index e213681..28df63c 100644 --- a/viewer/styles/responsive.css +++ b/viewer/styles/responsive.css @@ -1,32 +1,33 @@ @media (max-width: 1350px) { - .header-top { - gap: 12px; - } - - .header-context { - width: 100%; - grid-column: 1 / -1; - grid-row: 2; - } - - .selection-summary { - grid-template-columns: minmax(220px, 1fr) minmax(140px, auto) auto auto; - justify-content: end; - gap: 10px; + :root { + --panel-width: 340px; } - .selection-summary-item:not(.selection-summary-primary) .selection-summary-value { - max-width: 150px; + .header-top { + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + row-gap: 4px; } -} -@media (max-width: 1100px) { - :root { - --panel-width: 340px; + .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; } } @@ -48,7 +49,6 @@ border-top: 1px solid var(--border-color); } - .header-top, footer { display: flex; flex-wrap: wrap; @@ -56,7 +56,6 @@ .logo-section { min-width: 0; - width: 100%; } .logo { @@ -64,33 +63,38 @@ height: auto; } - .header-context, - .page-title { + .selection-summary { + display: grid; width: 100%; - align-items: flex-start; - 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; } - .header-context { - order: 3; + .selection-summary-primary, + .selection-summary-variable, + .selection-summary-time { + grid-column: 1; } - .selection-summary { - width: 100%; - grid-template-columns: repeat(2, minmax(0, 1fr)); - justify-content: flex-start; + .selection-summary-primary { + grid-row: 1; } - .selection-summary-primary { - grid-column: 1 / -1; + .selection-summary-variable { + grid-row: 2; } - .selection-summary-value { - max-width: min(240px, 60vw); + .selection-summary-time { + grid-row: 3; } - .selection-metadata-btn { - justify-self: start; + .btn.selection-metadata-btn { + align-self: center; + grid-column: 2; + grid-row: 1 / 4; } .color-row { @@ -103,12 +107,41 @@ } } +@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); } - .selection-summary-primary { - grid-column: auto; + .btn.selection-metadata-btn { + grid-column: 1; + grid-row: 4; + justify-self: start; + margin-top: 6px; } } From 15f0f16231082ff16ef4eb75b16d43c1a48fdaa2 Mon Sep 17 00:00:00 2001 From: Quintin Date: Mon, 27 Jul 2026 09:08:55 -0700 Subject: [PATCH 4/9] Preserve status log --- viewer/js/core/dom.js | 90 +++++++++++++++++++++++++++++ viewer/pdp-next-viewer.html | 12 +++- viewer/styles/layout.css | 111 +++++++++++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 2 deletions(-) diff --git a/viewer/js/core/dom.js b/viewer/js/core/dom.js index 6443530..c1e8394 100644 --- a/viewer/js/core/dom.js +++ b/viewer/js/core/dom.js @@ -4,6 +4,10 @@ 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'); @@ -37,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; } @@ -68,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); } @@ -75,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); } @@ -97,3 +185,5 @@ export function stopStatusSpinner(message, isError = false) { } if (message) setStatus(message, isError); } + +recordStatus(DEFAULT_READY_STATUS, false); diff --git a/viewer/pdp-next-viewer.html b/viewer/pdp-next-viewer.html index 3c1a069..2ca11cb 100644 --- a/viewer/pdp-next-viewer.html +++ b/viewer/pdp-next-viewer.html @@ -63,7 +63,17 @@

Climate Layer Opacity

- Ready + +
diff --git a/viewer/styles/layout.css b/viewer/styles/layout.css index fb59979..05b8a26 100644 --- a/viewer/styles/layout.css +++ b/viewer/styles/layout.css @@ -271,7 +271,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); @@ -280,3 +280,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; +} From 29585ad721104525b42b7bb7165f14205209a3b7 Mon Sep 17 00:00:00 2001 From: Quintin Date: Mon, 27 Jul 2026 09:48:30 -0700 Subject: [PATCH 5/9] Bump thredds-docker to include threddsiso --- thredds/Dockerfile.thredds | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From f3459342a6d0b4198088cdfefaf318e6e3e53a74 Mon Sep 17 00:00:00 2001 From: Quintin Date: Mon, 27 Jul 2026 09:52:27 -0700 Subject: [PATCH 6/9] Handle invalid time subset requests --- viewer/js/subsetting/download.js | 113 ++++++++++++++++++++++++++++--- viewer/styles/controls.css | 37 ++++++++++ 2 files changed, 140 insertions(+), 10 deletions(-) diff --git a/viewer/js/subsetting/download.js b/viewer/js/subsetting/download.js index c34dfae..b093a77 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,39 @@ 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 setSubsetDownloadBusy(isBusy) { subsetDownloadBtn.disabled = isBusy; subsetDownloadBtn.textContent = isBusy ? `${SUBSET_DOWNLOAD_LABEL}...` : SUBSET_DOWNLOAD_LABEL; @@ -252,20 +290,72 @@ 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] : []) + ]; + 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 || ''; + let rangeStart = startIso || ''; + let 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)}`; + if (requestedEndMs < availableStartMs || requestedStartMs > availableEndMs) { + const outOfRangeInputs = [ + ...(requestedStartMs < availableStartMs || requestedStartMs > availableEndMs ? [subsetTimeStart] : []), + ...(requestedEndMs < availableStartMs || requestedEndMs > availableEndMs ? [subsetTimeEnd] : []) + ]; + cancelInvalidTimeRange( + run, + 'date-range-outside-dataset', + `The requested dates do not overlap this dataset's available range (${availableLabel}). No download was started.`, + outOfRangeInputs + ); + } + + const adjustedInputs = []; + if (rangeStart && requestedStartMs < availableStartMs) { + rangeStart = fullRangeStart; + subsetTimeStart.value = dateInputValue(fullRangeStart); + adjustedInputs.push(subsetTimeStart); + } + if (rangeEnd && requestedEndMs > availableEndMs) { + rangeEnd = fullRangeEnd; + subsetTimeEnd.value = dateInputValue(fullRangeEnd); + adjustedInputs.push(subsetTimeEnd); + } + if (adjustedInputs.length) { + flagTimeInputs(adjustedInputs, 'is-adjusted'); + setStatus(`Subset dates were clipped to the available range (${availableLabel}).`); + } + } + 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 +633,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; diff --git a/viewer/styles/controls.css b/viewer/styles/controls.css index 0afcfb4..3aa1a5e 100644 --- a/viewer/styles/controls.css +++ b/viewer/styles/controls.css @@ -83,6 +83,43 @@ 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); +} + +.color-row input.is-adjusted { + border-color: #b26a00; + box-shadow: 0 0 0 1px rgba(178, 106, 0, 0.12); +} + +@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; From b61b9291ea15ae09a282f7738269c0bd64e63037 Mon Sep 17 00:00:00 2001 From: Quintin Date: Mon, 27 Jul 2026 10:15:29 -0700 Subject: [PATCH 7/9] Enable isoAllow in theddsConfig --- thredds/thredds-config/threddsConfig.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thredds/thredds-config/threddsConfig.xml b/thredds/thredds-config/threddsConfig.xml index 23cb98b..2a5274e 100644 --- a/thredds/thredds-config/threddsConfig.xml +++ b/thredds/thredds-config/threddsConfig.xml @@ -17,7 +17,7 @@ true false - false + true From 109645a4bec1c763f3d3427d56f5840e37c45ed3 Mon Sep 17 00:00:00 2001 From: Quintin Date: Mon, 27 Jul 2026 12:21:23 -0700 Subject: [PATCH 8/9] Handle bbox out-of-range, fix date validation --- viewer/js/subsetting/download.js | 96 +++++++++++++++++++++----------- viewer/js/time/parse.js | 7 +-- viewer/styles/controls.css | 5 -- 3 files changed, 68 insertions(+), 40 deletions(-) diff --git a/viewer/js/subsetting/download.js b/viewer/js/subsetting/download.js index b093a77..1b76c40 100644 --- a/viewer/js/subsetting/download.js +++ b/viewer/js/subsetting/download.js @@ -90,6 +90,12 @@ export function createSubsetDownloadController({ 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; @@ -227,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 }. @@ -234,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 }; @@ -247,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(); @@ -257,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 + }; } /** @@ -289,11 +330,11 @@ export function createSubsetDownloadController({ const startIso = parseSubsetDateValue(subsetTimeStart.value, 'start'); const endIso = parseSubsetDateValue(subsetTimeEnd.value, 'end'); - if (startIso === null || endIso === null) { - const invalidInputs = [ - ...(startIso === null ? [subsetTimeStart] : []), - ...(endIso === null ? [subsetTimeEnd] : []) - ]; + const invalidInputs = [ + ...(startIso === null ? [subsetTimeStart] : []), + ...(endIso === null ? [subsetTimeEnd] : []) + ]; + if (invalidInputs.length) { cancelInvalidTimeRange( run, 'invalid-date-input', @@ -301,8 +342,8 @@ export function createSubsetDownloadController({ invalidInputs ); } - let rangeStart = startIso || ''; - let rangeEnd = endIso || ''; + const rangeStart = startIso || ''; + const rangeEnd = endIso || ''; if (rangeStart && rangeEnd && Date.parse(rangeStart) > Date.parse(rangeEnd)) { cancelInvalidTimeRange( run, @@ -326,34 +367,27 @@ export function createSubsetDownloadController({ && Number.isFinite(requestedEndMs) ) { const availableLabel = `${dateInputValue(fullRangeStart)} to ${dateInputValue(fullRangeEnd)}`; - if (requestedEndMs < availableStartMs || requestedStartMs > availableEndMs) { + 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 = [ - ...(requestedStartMs < availableStartMs || requestedStartMs > availableEndMs ? [subsetTimeStart] : []), - ...(requestedEndMs < availableStartMs || requestedEndMs > availableEndMs ? [subsetTimeEnd] : []) + ...(startOutOfRange ? [subsetTimeStart] : []), + ...(endOutOfRange ? [subsetTimeEnd] : []) ]; cancelInvalidTimeRange( run, 'date-range-outside-dataset', - `The requested dates do not overlap this dataset's available range (${availableLabel}). No download was started.`, + `Start and end dates must be within this dataset's available range (${availableLabel}). No download was started.`, outOfRangeInputs ); } - - const adjustedInputs = []; - if (rangeStart && requestedStartMs < availableStartMs) { - rangeStart = fullRangeStart; - subsetTimeStart.value = dateInputValue(fullRangeStart); - adjustedInputs.push(subsetTimeStart); - } - if (rangeEnd && requestedEndMs > availableEndMs) { - rangeEnd = fullRangeEnd; - subsetTimeEnd.value = dateInputValue(fullRangeEnd); - adjustedInputs.push(subsetTimeEnd); - } - if (adjustedInputs.length) { - flagTimeInputs(adjustedInputs, 'is-adjusted'); - setStatus(`Subset dates were clipped to the available range (${availableLabel}).`); - } } if (state.times.length) { @@ -661,4 +695,4 @@ export function createSubsetDownloadController({ downloadSubset, cancelPendingSubsetStatus }; -} +} \ No newline at end of file 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/styles/controls.css b/viewer/styles/controls.css index 3aa1a5e..15047e8 100644 --- a/viewer/styles/controls.css +++ b/viewer/styles/controls.css @@ -92,11 +92,6 @@ box-shadow: 0 0 0 1px rgba(211, 47, 47, 0.15); } -.color-row input.is-adjusted { - border-color: #b26a00; - box-shadow: 0 0 0 1px rgba(178, 106, 0, 0.12); -} - @keyframes subset-time-shake { 0%, 100% { From a69f1011624718261b6fd2f4a348fc2b5274fa7a Mon Sep 17 00:00:00 2001 From: Quintin Date: Fri, 31 Jul 2026 09:52:29 -0700 Subject: [PATCH 9/9] Fix css for FF, add long name variable labels --- viewer/js/map/controller.js | 3 ++- viewer/js/portal/datasets.js | 2 ++ viewer/js/portal/menu.js | 20 +++++++++++++++--- viewer/styles/base.css | 7 ++++++ viewer/styles/layout.css | 41 +++++++++++++++++++++++------------- 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/viewer/js/map/controller.js b/viewer/js/map/controller.js index 403a4ce..56d98f4 100644 --- a/viewer/js/map/controller.js +++ b/viewer/js/map/controller.js @@ -417,7 +417,8 @@ export function createMapController({ state.currentDataset?.urlPath, ].filter(Boolean).join("\n"); const displayVariable = state.variable - ? variableLabel(state.variable, state.group) + ? state.currentDataset?.variableLabel + || variableLabel(state.variable, state.group) : "—"; variableInfo.textContent = displayVariable; if (variableIconElement) { diff --git a/viewer/js/portal/datasets.js b/viewer/js/portal/datasets.js index 749a11b..29c7721 100644 --- a/viewer/js/portal/datasets.js +++ b/viewer/js/portal/datasets.js @@ -224,6 +224,7 @@ export function createDatasetController({ selectionLabel = null, urlPath, variable, + variableLabel = null, metadata = null, rendering = null, timeMetadata = null, @@ -237,6 +238,7 @@ export function createDatasetController({ state.currentDataset = { name, selectionLabel, + variableLabel, urlPath, wmsBase: wmsBaseForUrlPath(urlPath), ncmlUrl: ncmlUrlForUrlPath(urlPath), diff --git a/viewer/js/portal/menu.js b/viewer/js/portal/menu.js index 6d8196b..67883f0 100644 --- a/viewer/js/portal/menu.js +++ b/viewer/js/portal/menu.js @@ -1,4 +1,7 @@ -import { KNOWN_PORTALS } 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')); @@ -75,6 +78,15 @@ export function createMenuController({ 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 = @@ -86,6 +98,7 @@ export function createMenuController({ 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 @@ -139,7 +152,7 @@ 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; registerSelectable(fileLi, entry, basename, pathNow); containerUl.appendChild(fileLi); @@ -150,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'; 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/layout.css b/viewer/styles/layout.css index 05b8a26..95781eb 100644 --- a/viewer/styles/layout.css +++ b/viewer/styles/layout.css @@ -2,15 +2,16 @@ position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); - grid-template-rows: auto; + grid-template-rows: auto auto; align-items: center; column-gap: 20px; + row-gap: 4px; } .logo-section { display: flex; grid-column: 1; - grid-row: 1; + grid-row: 1 / -1; align-items: center; gap: 20px; } @@ -23,28 +24,28 @@ width: 100%; min-width: 0; grid-column: 2; - grid-row: 1; - padding-top: 28px; + grid-row: 2; } .page-title { - position: absolute; - top: 0; - left: 50%; + grid-column: 2; + grid-row: 1; margin: 0; - transform: translateX(-50%); color: var(--pcic-light-blue); font-size: 24px; font-weight: 300; + line-height: 1.2; text-align: center; } .selection-summary { - display: flex; + 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; - gap: 24px; + column-gap: clamp(8px, 1.25vw, 24px); } .selection-summary-item { @@ -55,12 +56,13 @@ } .selection-summary-primary { + width: 100%; + min-width: 260px; max-width: 460px; - flex: 0 1 auto; } .selection-summary-label { - flex: 0 0 62px; + flex: 0 0 auto; color: var(--text-muted); font-size: 10px; font-weight: 600; @@ -70,12 +72,21 @@ .selection-summary-value { min-width: 0; - max-width: 100%; - overflow: hidden; color: var(--text-dark); font-size: 12px; font-weight: 600; - text-overflow: ellipsis; + 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; }