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
-
+
@@ -44,7 +63,17 @@ Climate Layer Opacity
@@ -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;
+ }
+}