From 99020c385562244e0e24ed537b90ae2c46d16c79 Mon Sep 17 00:00:00 2001 From: PavanSiligam Date: Fri, 13 Mar 2026 00:42:10 +0100 Subject: [PATCH 01/40] feat(item-card): show collection badge on item cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated info-variant badge showing the item's collection ID on every item card. Reads from the top-level `collection` field of the STAC Item object, which is always populated for items returned by the ESM Catalog API. This makes it easy to visually distinguish items across experiments when viewing cross-collection search results (e.g. after applying Additional Filters in the Search for Items tab). The badge is rendered first, before file-format and deprecated badges, using variant="info" so it is visually distinct from both. The collection badge is independent of the showKeywordsInItemCards setting — it is always shown when the item has a collection field. Co-Authored-By: Claude Sonnet 4.6 --- src/components/Item.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Item.vue b/src/components/Item.vue index 3505d60ce0..237671432c 100644 --- a/src/components/Item.vue +++ b/src/components/Item.vue @@ -7,7 +7,8 @@ - + + {{ collectionId }} {{ $t('deprecated') }} {{ format }} @@ -63,6 +64,9 @@ export default defineComponent({ description: this.hasDescription }; }, + collectionId() { + return this.data && this.data.collection ? this.data.collection : null; + }, extent() { if (this.data && (this.data.properties.start_datetime || this.data.properties.end_datetime)) { return [this.data.properties.start_datetime, this.data.properties.end_datetime]; From fd373d597547d36f000018581b782c085df8700b Mon Sep 17 00:00:00 2001 From: PavanSiligam Date: Fri, 13 Mar 2026 01:39:11 +0100 Subject: [PATCH 02/40] fix(filters): use cql2-json instead of cql2-text for GET filter requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STAC Browser previously serialised CQL2 filters as cql2-text when attaching them to GET requests (e.g. GET /collections/{id}/items). The ESM Catalog API only implements a cql2-json parser, so the text filter was silently ignored and the Submit button appeared to have no effect. Switch value.toText() → value.toJSON() in Utils.addFiltersToLink so GET requests carry ?filter-lang=cql2-json&filter={...} — consistent with the POST /search path and supported by the API. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 12 +++++++++++- src/utils.js | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef69bf1d93..89326580b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2052,7 +2052,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/rbush": { "version": "4.0.0", @@ -2308,6 +2309,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2330,6 +2332,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2895,6 +2898,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3528,6 +3532,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5139,6 +5144,7 @@ "resolved": "https://registry.npmjs.org/ol/-/ol-10.7.0.tgz", "integrity": "sha512-122U5gamPqNgLpLOkogFJhgpywvd/5en2kETIDW+Ubfi9lPnZ0G9HWRdG+CX0oP8od2d6u6ky3eewIYYlrVczw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@types/rbush": "4.0.0", "earcut": "^3.0.0", @@ -5939,6 +5945,7 @@ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6167,6 +6174,7 @@ "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -6906,6 +6914,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7118,6 +7127,7 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz", "integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/generator": "^7.28.6", "@vue-macros/common": "^3.1.1", diff --git a/src/utils.js b/src/utils.js index 6bfe688c1f..2ff062e417 100644 --- a/src/utils.js +++ b/src/utils.js @@ -240,7 +240,7 @@ export default class Utils { value = value.join(','); } else if (key === 'filters') { - let params = value.toText(); + let params = value.toJSON(); url.setQuery(params); continue; } From a65f0c6c0b6de7a9b2412a46c4e45b9f5c273a21 Mon Sep 17 00:00:00 2001 From: PavanSiligam Date: Fri, 13 Mar 2026 02:01:49 +0100 Subject: [PATCH 03/40] revert(filters): restore cql2-text for GET filter requests The previous commit changed value.toText() to value.toJSON() expecting the API to parse JSON-encoded filters on GET requests. The API now supports cql2-text natively via _parse_cql2_text(), so the stac-browser change is no longer needed and is reverted to keep standard behaviour. Co-Authored-By: Claude Sonnet 4.6 --- src/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.js b/src/utils.js index 2ff062e417..6bfe688c1f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -240,7 +240,7 @@ export default class Utils { value = value.join(','); } else if (key === 'filters') { - let params = value.toJSON(); + let params = value.toText(); url.setQuery(params); continue; } From 923dfc67f71137c56fd73d12a8a9472dbfbb8d34 Mon Sep 17 00:00:00 2001 From: PavanSiligam Date: Fri, 13 Mar 2026 02:14:01 +0100 Subject: [PATCH 04/40] fix(cql2): fix CqlNot.toText() to emit NOT (inner) correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CqlNot inherited toText() from CqlOperator which uses Array.join() with the operator as separator. For a single-element args array, join() returns just the element text with no separator — silently dropping the NOT. Override toText() in CqlNot to produce the correct CQL2-text syntax: NOT (inner expression) Without this fix, the "Negate filter" checkbox had no effect on GET requests because the NOT was lost before the filter reached the API. Co-Authored-By: Claude Sonnet 4.6 --- src/models/cql2/operators/logical.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/models/cql2/operators/logical.js b/src/models/cql2/operators/logical.js index e18a093f6a..724326f832 100644 --- a/src/models/cql2/operators/logical.js +++ b/src/models/cql2/operators/logical.js @@ -73,4 +73,13 @@ export class CqlNot extends CqlLogicalOperator { return i18n.global.t('search.logical.not'); } + toText() { + // The base-class join() on a single-element array drops the operator. + // Override to produce correct CQL2-text syntax: NOT (inner expression). + if (this.args && this.args.length === 1) { + return `NOT (${this.args[0].toText()})`; + } + return super.toText(); + } + } From 65fe4d23d67a96ee5b79cc74b8142b9c738c3d84 Mon Sep 17 00:00:00 2001 From: Paul Gierz Date: Fri, 13 Mar 2026 15:25:15 -0700 Subject: [PATCH 05/40] Add DataPreview Vue component for visualizing STAC item data Introduces a reusable component that fetches metadata from a visualization server and displays interactive previews of geospatial data. Features include variable selection, time dimension slider, colormap options, and error handling. --- src/components/DataPreview.vue | 184 +++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/components/DataPreview.vue diff --git a/src/components/DataPreview.vue b/src/components/DataPreview.vue new file mode 100644 index 0000000000..eacf6871c2 --- /dev/null +++ b/src/components/DataPreview.vue @@ -0,0 +1,184 @@ + + + + + From af936832eccfbf631254b461e9f752cf36be8614 Mon Sep 17 00:00:00 2001 From: Paul Gierz Date: Fri, 13 Mar 2026 15:27:48 -0700 Subject: [PATCH 06/40] Integrate DataPreview component into Item view with vizServer config - Add DataPreview as a collapsible section in the Item view - Lazy-load preview content only when section is expanded - Add vizServer configuration option for visualization server URL - Update config schema to include vizServer property --- config.js | 3 ++- config.schema.json | 8 ++++++++ src/views/Item.vue | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/config.js b/config.js index abbcb49fe5..e385e6b238 100644 --- a/config.js +++ b/config.js @@ -54,5 +54,6 @@ export default { preprocessSTAC: null, authConfig: null, crs: {}, - footerLinks: null + footerLinks: null, + vizServer: null // URL of visualization server for data previews (e.g., 'http://localhost:8001') }; diff --git a/config.schema.json b/config.schema.json index b75a418f8c..415de6fec7 100644 --- a/config.schema.json +++ b/config.schema.json @@ -264,6 +264,14 @@ "url" ] } + }, + "vizServer": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "URL of visualization server for data previews" } } } diff --git a/src/views/Item.vue b/src/views/Item.vue index b408ca7ea3..98a8439280 100644 --- a/src/views/Item.vue +++ b/src/views/Item.vue @@ -30,6 +30,27 @@ + + +
+

+ + {{ previewExpanded ? '[-]' : '[+]' }} {{ $t('dataPreview') || 'Data Preview' }} + +

+ + + +
@@ -38,7 +59,7 @@ + + diff --git a/src/components/CollectionComparison.vue b/src/components/CollectionComparison.vue new file mode 100644 index 0000000000..b520b382bf --- /dev/null +++ b/src/components/CollectionComparison.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/components/CompareButton.vue b/src/components/CompareButton.vue new file mode 100644 index 0000000000..8804cab9aa --- /dev/null +++ b/src/components/CompareButton.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/components/DataPreview.vue b/src/components/DataPreview.vue index 7cb013617f..1cd12bb21b 100644 --- a/src/components/DataPreview.vue +++ b/src/components/DataPreview.vue @@ -156,11 +156,16 @@ export default defineComponent({ }, interactiveUrl() { if (!this.item?.id || this.isCollection) return null; - let url = `${this.vizServer}/app?item_id=${this.item.id}&stac_api=${encodeURIComponent(this.stacApi)}`; + // Panel app is served at /panel/ with query parameters + // item_id is passed as a query param, not a path segment + const params = new URLSearchParams({ + item_id: this.item.id, + stac_api: this.stacApi + }); if (this.collectionId) { - url += `&collection_id=${encodeURIComponent(this.collectionId)}`; + params.set('collection_id', this.collectionId); } - return url; + return `${this.vizServer}/panel/?${params}`; }, staticPreviewUrl() { if (!this.selectedVariable || !this.item?.id || this.isCollection) return null; diff --git a/src/components/GlossaryTooltip.vue b/src/components/GlossaryTooltip.vue new file mode 100644 index 0000000000..613db0e35d --- /dev/null +++ b/src/components/GlossaryTooltip.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/components/MetadataGroups.vue b/src/components/MetadataGroups.vue index 8e3082fefa..ccc97382f1 100644 --- a/src/components/MetadataGroups.vue +++ b/src/components/MetadataGroups.vue @@ -1,18 +1,45 @@ @@ -146,6 +275,40 @@ export default { #stac-browser { .metadata { + .metadata-header { + display: flex; + align-items: baseline; + gap: 0.5rem; + + .field-count { + font-size: 0.7em; + font-weight: normal; + color: #6c757d; + } + } + + .metadata-controls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + + .search-container { + flex: 1; + min-width: 200px; + max-width: 400px; + + .metadata-search { + width: 100%; + } + } + + .collapse-controls { + display: flex; + gap: 0.5rem; + } + } + .card { border: 0; margin-top: $block-margin; diff --git a/src/components/QueryableInput.vue b/src/components/QueryableInput.vue index 8e0df1ea80..37ac82b9d5 100644 --- a/src/components/QueryableInput.vue +++ b/src/components/QueryableInput.vue @@ -91,6 +91,23 @@ :model-value="value.value" @update:model-value="updateValue($event)" /> + + + this.castArrayItem(v))); } + }, + // Check if this queryable has GHG unit metadata + hasGhgUnits() { + return this.schema && this.schema['x-ghg-units']; + }, + // Get available unit options for GHG fields + ghgUnitOptions() { + if (!this.hasGhgUnits) return []; + const units = this.schema['x-ghg-units'].supported || ['decimal']; + return units.map(u => ({ + value: u, + text: u.toUpperCase() + })); + }, + // Conversion factors for GHG units + ghgConversionFactors() { + return { + 'decimal': 1.0, + 'ppm': 1e-6, + 'ppb': 1e-9 + }; + }, + // Display value converted to selected unit + displayValue() { + if (!this.hasGhgUnits || !this.selectedUnit) { + return this.value?.value; + } + // Internal value is in decimal, convert to display unit + const internalValue = this.value?.value; + if (internalValue == null || internalValue === '') return ''; + const factor = this.ghgConversionFactors[this.selectedUnit] || 1.0; + // Display value = internal value / factor + return factor === 1.0 ? internalValue : (internalValue / factor); + } + }, + watch: { + // When unit changes, we need to reconvert the value + selectedUnit(newUnit, oldUnit) { + if (!this.hasGhgUnits || !oldUnit || !newUnit || oldUnit === newUnit) return; + // The internal value stays the same (in decimal) + // The display just changes based on selected unit + // No update needed since displayValue is computed } }, methods: { @@ -322,6 +389,22 @@ export default { }, getEventValue(event) { return isObject(event) && 'target' in event ? event.target.value : event; + }, + // Update value for GHG fields, converting from display unit to decimal + updateGhgValue(evt) { + let displayVal = this.getEventValue(evt); + if (displayVal === '' || displayVal == null) { + this.emitValue(CqlValue.create(null)); + return; + } + displayVal = parseFloat(displayVal); + if (isNaN(displayVal)) { + return; + } + // Convert from display unit to internal decimal value + const factor = this.ghgConversionFactors[this.selectedUnit] || 1.0; + const internalVal = displayVal * factor; + this.emitValue(CqlValue.create(internalVal)); } } }; @@ -348,6 +431,15 @@ export default { display: flex; gap: 0.2em; } + .value.ghg-value { + flex-grow: 3; + width: 5rem !important; + } + .unit-selector { + flex-grow: 1; + width: 4rem !important; + min-width: 4rem; + } .op { min-width: 4rem; width: auto; diff --git a/src/components/SearchFilter.vue b/src/components/SearchFilter.vue index 643ed4526a..6bfeac9347 100644 --- a/src/components/SearchFilter.vue +++ b/src/components/SearchFilter.vue @@ -43,10 +43,8 @@ /> - - {{ $t('search.filterBySpatialExtent') }} - - + + @@ -91,6 +89,50 @@ + +
+ +
+ + to + +
+
+ + +
+ +
+ + to + +
+
+
@@ -297,6 +339,7 @@ import Queryable from '../models/cql2/queryable'; import CqlLogicalOperator, { CqlNot, CqlAnd } from '../models/cql2/operators/logical'; import { CqlGreaterThanEqual, CqlLessThanEqual, CqlLike } from '../models/cql2/operators/comparison'; import { CqlIn } from '../models/cql2/operators/array'; +import CqlValue from '../models/cql2/value'; import { stacRequest } from '../store/utils'; function getQueryDefaults() { @@ -323,7 +366,18 @@ function getDefaults() { filtersAndOr: 'and', filtersNegate: false, filters: [], - selectedCollections: [] + selectedCollections: [], + // Quick filter defaults (for reset) + selectedComponents: [], + co2Min: null, + co2Max: null, + ch4Min: null, + ch4Max: null, + n2oMin: null, + n2oMax: null, + experimentType: null, + outputFrequency: null, + selectedPaleoPreset: null }; } @@ -383,6 +437,10 @@ export default defineComponent({ selectedComponents: [], co2Min: null, co2Max: null, + ch4Min: null, + ch4Max: null, + n2oMin: null, + n2oMax: null, experimentType: null, outputFrequency: null, paleoPresets: [], @@ -850,17 +908,17 @@ export default defineComponent({ const quickFilters = this.buildQuickFilters(); if (quickFilters.length > 0) { const quickCqlArgs = quickFilters.map(qf => { - // Create a simple queryable for the field - const queryable = { id: qf.field }; + // Create a queryable-like object with required methods + const queryable = new Queryable(qf.field, { type: 'number' }); if (qf.op === '>=') { - return new CqlGreaterThanEqual(queryable, qf.value); + return new CqlGreaterThanEqual(queryable, CqlValue.create(qf.value)); } else if (qf.op === '<=') { - return new CqlLessThanEqual(queryable, qf.value); + return new CqlLessThanEqual(queryable, CqlValue.create(qf.value)); } else if (qf.op === 'like') { - return new CqlLike(queryable, qf.value); + return new CqlLike(queryable, CqlValue.create(qf.value)); } else if (qf.op === 'in' && qf.values) { - return new CqlIn(queryable, qf.values); + return new CqlIn(queryable, CqlValue.create(qf.values)); } return null; }).filter(f => f !== null); @@ -1020,6 +1078,50 @@ export default defineComponent({ } } + // CH4 range filter (converted from ppb to decimal) + if (this.ch4Min !== null || this.ch4Max !== null) { + const ch4Field = 'nml:radctl:ch4vmr'; + if (this.ch4Min !== null) { + // Convert ppb to decimal (volume mixing ratio) + const minDecimal = this.ch4Min * 1e-9; + quickFilters.push({ + field: ch4Field, + op: '>=', + value: minDecimal + }); + } + if (this.ch4Max !== null) { + const maxDecimal = this.ch4Max * 1e-9; + quickFilters.push({ + field: ch4Field, + op: '<=', + value: maxDecimal + }); + } + } + + // N2O range filter (converted from ppb to decimal) + if (this.n2oMin !== null || this.n2oMax !== null) { + const n2oField = 'nml:radctl:n2ovmr'; + if (this.n2oMin !== null) { + // Convert ppb to decimal (volume mixing ratio) + const minDecimal = this.n2oMin * 1e-9; + quickFilters.push({ + field: n2oField, + op: '>=', + value: minDecimal + }); + } + if (this.n2oMax !== null) { + const maxDecimal = this.n2oMax * 1e-9; + quickFilters.push({ + field: n2oField, + op: '<=', + value: maxDecimal + }); + } + } + // Experiment type filter (pattern match on collection ID or explicit field) if (this.experimentType) { quickFilters.push({ diff --git a/src/components/metadata/MetadataEntry.vue b/src/components/metadata/MetadataEntry.vue index 28a5224ce6..816cee97c1 100644 --- a/src/components/metadata/MetadataEntry.vue +++ b/src/components/metadata/MetadataEntry.vue @@ -1,16 +1,19 @@ @@ -31,19 +34,33 @@ const CODE_SNIPPET_FIELDS = [ 'xarray_snippet' ]; +const TREE_VIEW_FIELDS = [ + 'nml:parameters' +]; + export default { name: "MetadataEntry", components: { MetadataTable: defineAsyncComponent(() => import('./MetadataTable.vue')), - CodeSnippet: defineAsyncComponent(() => import('./CodeSnippet.vue')) + CodeSnippet: defineAsyncComponent(() => import('./CodeSnippet.vue')), + NamelistTree: defineAsyncComponent(() => import('./NamelistTree.vue')) }, mixins: [ EntryMixin ], + props: { + highlight: { + type: String, + default: '' + } + }, computed: { showTable() { return FORCE_TABLE.includes(this.field) || this.itemOrder.length > 0 && size(this.value) >= 3; }, + showTree() { + return TREE_VIEW_FIELDS.includes(this.field) && typeof this.value === 'object' && this.value !== null; + }, isCodeSnippet() { return CODE_SNIPPET_FIELDS.includes(this.field) && typeof this.value === 'string'; }, @@ -52,7 +69,49 @@ export default { return 'python'; } return 'text'; + }, + highlightedLabel() { + return this.applyHighlight(this.label); + }, + highlightedFormatted() { + return this.applyHighlight(this.formatted); + } + }, + methods: { + applyHighlight(text) { + if (!this.highlight || !text) { + return text; + } + + const query = this.highlight.toLowerCase(); + const lowerText = text.toLowerCase(); + + // Find all occurrences and highlight them + let result = ''; + let lastIndex = 0; + let searchIndex = 0; + + while ((searchIndex = lowerText.indexOf(query, lastIndex)) !== -1) { + // Add text before the match + result += text.substring(lastIndex, searchIndex); + // Add the highlighted match (preserving original case) + result += '' + text.substring(searchIndex, searchIndex + this.highlight.length) + ''; + lastIndex = searchIndex + this.highlight.length; + } + + // Add remaining text + result += text.substring(lastIndex); + + return result; } } }; + + diff --git a/src/components/metadata/MetadataGroup.vue b/src/components/metadata/MetadataGroup.vue index c35aebca7a..f164ef5853 100644 --- a/src/components/metadata/MetadataGroup.vue +++ b/src/components/metadata/MetadataGroup.vue @@ -1,39 +1,92 @@ + + diff --git a/src/components/metadata/NamelistTree.vue b/src/components/metadata/NamelistTree.vue new file mode 100644 index 0000000000..d589db4195 --- /dev/null +++ b/src/components/metadata/NamelistTree.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/src/data/glossary.json b/src/data/glossary.json new file mode 100644 index 0000000000..7f27d27440 --- /dev/null +++ b/src/data/glossary.json @@ -0,0 +1,227 @@ +{ + "T31": { + "term": "T31", + "category": "resolution", + "description": "Atmosphere spectral resolution: ~3.75 degrees (~400km)" + }, + "T63": { + "term": "T63", + "category": "resolution", + "description": "Atmosphere spectral resolution: ~1.875 degrees (~200km)" + }, + "T127": { + "term": "T127", + "category": "resolution", + "description": "Atmosphere spectral resolution: ~0.9 degrees (~100km)" + }, + "T255": { + "term": "T255", + "category": "resolution", + "description": "Atmosphere spectral resolution: ~0.5 degrees (~50km)" + }, + "CORE2": { + "term": "CORE2", + "category": "mesh", + "description": "Ocean mesh: ~1 degree global resolution (Coordinated Ocean-ice Reference Experiments)" + }, + "GLOB": { + "term": "GLOB", + "category": "mesh", + "description": "Ocean mesh: variable resolution, global coverage" + }, + "pi": { + "term": "PI-Mesh", + "category": "mesh", + "description": "Ocean mesh: high resolution around the poles" + }, + "vmr": { + "term": "VMR", + "category": "units", + "description": "Volume Mixing Ratio - concentration as a decimal fraction" + }, + "ppm": { + "term": "ppm", + "category": "units", + "description": "Parts per million (10^-6)" + }, + "ppb": { + "term": "ppb", + "category": "units", + "description": "Parts per billion (10^-9)" + }, + "ECHAM": { + "term": "ECHAM", + "category": "model", + "description": "Atmosphere general circulation model (MPI-M Hamburg)" + }, + "ECHAM6": { + "term": "ECHAM6", + "category": "model", + "description": "ECHAM version 6 atmosphere model" + }, + "FESOM": { + "term": "FESOM", + "category": "model", + "description": "Finite Element Sea ice-Ocean Model (AWI) - unstructured mesh ocean model" + }, + "FESOM2": { + "term": "FESOM2", + "category": "model", + "description": "FESOM version 2 - improved performance and physics" + }, + "JSBACH": { + "term": "JSBACH", + "category": "model", + "description": "Jena Scheme for Biosphere-Atmosphere Coupling in Hamburg - land surface model" + }, + "HDMODEL": { + "term": "HDMODEL", + "category": "model", + "description": "Hydrological Discharge Model - river routing scheme" + }, + "OASIS": { + "term": "OASIS", + "category": "model", + "description": "Ocean Atmosphere Sea Ice Soil coupler framework" + }, + "OASIS3MCT": { + "term": "OASIS3-MCT", + "category": "model", + "description": "OASIS coupler with Model Coupling Toolkit" + }, + "RECOM": { + "term": "REcoM", + "category": "model", + "description": "Regulated Ecosystem Model - marine biogeochemistry" + }, + "HAMOCC": { + "term": "HAMOCC", + "category": "model", + "description": "Hamburg Ocean Carbon Cycle Model - marine biogeochemistry" + }, + "PISM": { + "term": "PISM", + "category": "model", + "description": "Parallel Ice Sheet Model" + }, + "AWI-CM": { + "term": "AWI-CM", + "category": "model", + "description": "AWI Climate Model - coupled ECHAM+FESOM" + }, + "AWI-ESM": { + "term": "AWI-ESM", + "category": "model", + "description": "AWI Earth System Model - includes biogeochemistry" + }, + "MPI-ESM": { + "term": "MPI-ESM", + "category": "model", + "description": "MPI Earth System Model (Hamburg)" + }, + "PI": { + "term": "PI-Control", + "category": "experiment", + "description": "Pre-industrial control run - constant 1850 conditions" + }, + "piControl": { + "term": "piControl", + "category": "experiment", + "description": "Pre-industrial control simulation (CMIP convention)" + }, + "LGM": { + "term": "LGM", + "category": "experiment", + "description": "Last Glacial Maximum - peak ice extent ~21,000 years ago" + }, + "MH": { + "term": "Mid-Holocene", + "category": "experiment", + "description": "Mid-Holocene warm period ~6,000 years ago" + }, + "historical": { + "term": "Historical", + "category": "experiment", + "description": "Historical simulation with observed forcing (typically 1850-2014)" + }, + "spinup": { + "term": "Spin-up", + "category": "experiment", + "description": "Initial equilibration run before main experiment" + }, + "ka": { + "term": "ka", + "category": "time", + "description": "Thousands of years ago (kilo-annum)" + }, + "Ma": { + "term": "Ma", + "category": "time", + "description": "Millions of years ago (mega-annum)" + }, + "BP": { + "term": "BP", + "category": "time", + "description": "Before Present (reference year 1950)" + }, + "BCE": { + "term": "BCE", + "category": "time", + "description": "Before Common Era (negative years)" + }, + "CE": { + "term": "CE", + "category": "time", + "description": "Common Era (positive years)" + }, + "mon": { + "term": "Monthly", + "category": "frequency", + "description": "Monthly mean output" + }, + "day": { + "term": "Daily", + "category": "frequency", + "description": "Daily mean output" + }, + "6hr": { + "term": "6-hourly", + "category": "frequency", + "description": "6-hourly output (4 times per day)" + }, + "3hr": { + "term": "3-hourly", + "category": "frequency", + "description": "3-hourly output (8 times per day)" + }, + "1hr": { + "term": "Hourly", + "category": "frequency", + "description": "Hourly output" + }, + "radctl": { + "term": "radctl", + "category": "namelist", + "description": "Radiation control namelist group" + }, + "runctl": { + "term": "runctl", + "category": "namelist", + "description": "Run control namelist group" + }, + "co2vmr": { + "term": "CO2 VMR", + "category": "parameter", + "description": "CO2 volume mixing ratio (concentration)" + }, + "ch4vmr": { + "term": "CH4 VMR", + "category": "parameter", + "description": "Methane volume mixing ratio" + }, + "n2ovmr": { + "term": "N2O VMR", + "category": "parameter", + "description": "Nitrous oxide volume mixing ratio" + } +} diff --git a/src/store/comparison.js b/src/store/comparison.js new file mode 100644 index 0000000000..0ae23e8a7d --- /dev/null +++ b/src/store/comparison.js @@ -0,0 +1,127 @@ +/** + * Vuex module for collection comparison feature. + * + * Allows users to select 2-3 collections for side-by-side comparison + * of their namelist parameters and other properties. + */ + +const MAX_SELECTIONS = 3; + +export default function() { + return { + namespaced: true, + state: () => ({ + // Selected collection IDs for comparison + selectedCollections: [], + // Full collection data (fetched when comparison modal opens) + collectionData: {}, + // Whether the comparison modal is open + isModalOpen: false + }), + getters: { + // Number of selected collections + selectionCount: state => state.selectedCollections.length, + // Check if a collection is selected + isSelected: state => collectionId => state.selectedCollections.includes(collectionId), + // Can we add more selections? + canAddMore: state => state.selectedCollections.length < MAX_SELECTIONS, + // Are we ready to compare? (need at least 2) + canCompare: state => state.selectedCollections.length >= 2, + // Get all namelist parameters across selected collections + allParameters: state => { + const params = new Set(); + for (const data of Object.values(state.collectionData)) { + if (data && typeof data === 'object') { + for (const key of Object.keys(data)) { + if (key.startsWith('nml:')) { + params.add(key); + } + } + } + } + return Array.from(params).sort(); + }, + // Get parameter value for a collection + getParamValue: state => (collectionId, paramKey) => { + const data = state.collectionData[collectionId]; + if (!data) return null; + return data[paramKey] ?? data.properties?.[paramKey] ?? null; + }, + // Check if a parameter has different values across collections + hasDifference: (state, getters) => paramKey => { + const values = state.selectedCollections.map(id => + getters.getParamValue(id, paramKey) + ); + const uniqueValues = new Set(values.map(v => JSON.stringify(v))); + return uniqueValues.size > 1; + } + }, + mutations: { + // Toggle selection of a collection + toggleSelection(state, collectionId) { + const index = state.selectedCollections.indexOf(collectionId); + if (index === -1) { + // Add if not at max + if (state.selectedCollections.length < MAX_SELECTIONS) { + state.selectedCollections.push(collectionId); + } + } else { + // Remove + state.selectedCollections.splice(index, 1); + // Also remove cached data + delete state.collectionData[collectionId]; + } + }, + // Clear all selections + clearSelections(state) { + state.selectedCollections = []; + state.collectionData = {}; + }, + // Store fetched collection data + setCollectionData(state, { collectionId, data }) { + state.collectionData[collectionId] = data; + }, + // Open/close comparison modal + setModalOpen(state, isOpen) { + state.isModalOpen = isOpen; + } + }, + actions: { + // Toggle selection and trigger data fetch if needed + toggleCollection({ commit, state }, collectionId) { + commit('toggleSelection', collectionId); + }, + // Open comparison modal and fetch any missing data + async openComparison({ commit, state, dispatch }) { + commit('setModalOpen', true); + // Fetch data for any collections we don't have yet + for (const id of state.selectedCollections) { + if (!state.collectionData[id]) { + await dispatch('fetchCollectionData', id); + } + } + }, + // Close comparison modal + closeComparison({ commit }) { + commit('setModalOpen', false); + }, + // Fetch full collection data for comparison + async fetchCollectionData({ commit, rootState }, collectionId) { + try { + // Look up in the store's database + const stacData = rootState.database[collectionId]; + if (stacData && typeof stacData === 'object' && !(stacData instanceof Error)) { + commit('setCollectionData', { collectionId, data: stacData }); + return; + } + // If not in cache, we might need to fetch it + // For now, just store what we have + commit('setCollectionData', { collectionId, data: null }); + } catch (error) { + console.warn('Failed to fetch collection data for comparison:', error); + commit('setCollectionData', { collectionId, data: null }); + } + } + } + }; +} diff --git a/src/store/index.js b/src/store/index.js index 47c1321604..347583f449 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -10,6 +10,7 @@ import { addMissingChildren, getDisplayTitle, createSTAC } from '../models/stac' import { CatalogLike, STAC } from 'stac-js'; import auth from './auth.js'; +import comparison from './comparison.js'; import { addQueryIfNotExists, hasAuthority, isAuthenticationError, Loading, processSTAC, stacRequest, stacRequestOptions } from './utils'; import { getBest } from 'stac-js/src/locales'; import fieldsI18n from '@radiantearth/stac-fields/I18N'; @@ -54,7 +55,8 @@ function getStore(config, router) { return createStore({ strict: import.meta.env.NODE_ENV !== 'production', modules: { - auth: auth(router) + auth: auth(router), + comparison: comparison() }, state: Object.assign({}, config, localDefaults(), catalogDefaults(), { // Global settings diff --git a/src/views/Catalog.vue b/src/views/Catalog.vue index 835dd0b00c..f7b4899d22 100644 --- a/src/views/Catalog.vue +++ b/src/views/Catalog.vue @@ -23,15 +23,25 @@ -
+
- - + + + + + + + +
@@ -86,6 +96,7 @@ export default defineComponent({ Assets: defineAsyncComponent(() => import('../components/Assets.vue')), Catalogs, CollectionLink: defineAsyncComponent(() => import('../components/CollectionLink.vue')), + DataPreview: defineAsyncComponent(() => import('../components/DataPreview.vue')), DeprecationNotice: defineAsyncComponent(() => import('../components/DeprecationNotice.vue')), Description, Items, @@ -142,8 +153,12 @@ export default defineComponent({ }; }, computed: { - ...mapState(['data', 'url', 'apiItems', 'apiItemsLink', 'apiItemsPagination', 'apiItemsNumberMatched', 'nextCollectionsLink', 'stateQueryParameters']), + ...mapState(['data', 'url', 'catalogUrl', 'apiItems', 'apiItemsLink', 'apiItemsPagination', 'apiItemsNumberMatched', 'nextCollectionsLink', 'stateQueryParameters', 'vizServer']), ...mapGetters(['catalogs', 'collectionLink', 'isCollection', 'items', 'getApiItemsLoading', 'parentLink', 'rootLink']), + stacApi() { + // Use catalogUrl as the STAC API endpoint + return this.catalogUrl || ''; + }, cssStacType() { if (hasText(this.data?.type)) { return this.data?.type.toLowerCase(); diff --git a/src/views/Item.vue b/src/views/Item.vue index 98a8439280..206700b85c 100644 --- a/src/views/Item.vue +++ b/src/views/Item.vue @@ -5,12 +5,22 @@
- - + + + + + + + +
@@ -30,27 +40,6 @@ - - -
-

- - {{ previewExpanded ? '[-]' : '[+]' }} {{ $t('dataPreview') || 'Data Preview' }} - -

- - - -
@@ -59,7 +48,7 @@ + + + `; +}; + +test.describe('DataPreview Component', () => { + + test.describe('Mode Toggle', () => { + + test('should display mode toggle buttons with Interactive selected by default', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Verify both toggle buttons are visible + const interactiveBtn = page.locator('#btn-interactive'); + const staticBtn = page.locator('#btn-static'); + + await expect(interactiveBtn).toBeVisible(); + await expect(staticBtn).toBeVisible(); + + // Verify Interactive is selected (has btn-primary class) + await expect(interactiveBtn).toHaveClass(/btn-primary/); + await expect(staticBtn).toHaveClass(/btn-outline-secondary/); + }); + + test('should switch to static mode when Static button is clicked', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + const interactiveBtn = page.locator('#btn-interactive'); + const staticBtn = page.locator('#btn-static'); + const interactivePreview = page.locator('#interactive-preview'); + const staticPreview = page.locator('#static-preview'); + + // Click Static button + await staticBtn.click(); + + // Verify Static is now selected + await expect(staticBtn).toHaveClass(/btn-primary/); + await expect(interactiveBtn).toHaveClass(/btn-outline-secondary/); + + // Verify static preview is visible, interactive is hidden + await expect(staticPreview).toBeVisible(); + await expect(interactivePreview).not.toBeVisible(); + }); + + test('should switch back to interactive mode when Interactive button is clicked', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + const interactiveBtn = page.locator('#btn-interactive'); + const staticBtn = page.locator('#btn-static'); + const interactivePreview = page.locator('#interactive-preview'); + const staticPreview = page.locator('#static-preview'); + + // Switch to static first + await staticBtn.click(); + await expect(staticPreview).toBeVisible(); + + // Switch back to interactive + await interactiveBtn.click(); + + // Verify Interactive is selected again + await expect(interactiveBtn).toHaveClass(/btn-primary/); + await expect(staticBtn).toHaveClass(/btn-outline-secondary/); + + // Verify interactive preview is visible + await expect(interactivePreview).toBeVisible(); + await expect(staticPreview).not.toBeVisible(); + }); + }); + + test.describe('Interactive Mode', () => { + + test('should show loading indicator before iframe loads', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Initially, loading indicator should be visible + const loadingIndicator = page.locator('#iframe-loading'); + await expect(loadingIndicator).toBeVisible(); + await expect(loadingIndicator).toContainText('Loading interactive preview'); + }); + + test('should show iframe after it loads successfully', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + const iframe = page.locator('#preview-iframe'); + const loadingIndicator = page.locator('#iframe-loading'); + + // Manually trigger the iframe load by calling the handler + // (iframe onload doesn't work with page.setContent for cross-origin src) + await page.evaluate(() => { + if (typeof handleIframeLoad === 'function') { + handleIframeLoad(); + } + }); + + // Verify iframe is visible and loading is hidden + await expect(iframe).toBeVisible(); + await expect(loadingIndicator).not.toBeVisible(); + }); + + test('should construct correct iframe URL with item and STAC API parameters', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + const iframe = page.locator('#preview-iframe'); + const src = await iframe.getAttribute('src'); + + // Verify URL structure + expect(src).toContain(`${VIZ_SERVER_URL}/panel/`); + expect(src).toContain(`item_id=${STAC_ITEM_FIXTURE.id}`); + expect(src).toContain(`stac_api=${encodeURIComponent(STAC_API_URL)}`); + expect(src).toContain(`collection_id=${STAC_ITEM_FIXTURE.collection}`); + }); + }); + + test.describe('Static Mode', () => { + + test('should show loading indicator when switching to static mode', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Loading indicator exists (may be hidden quickly when mock data loads fast) + const loadingIndicator = page.locator('#static-loading'); + await expect(loadingIndicator).toBeAttached(); + await expect(loadingIndicator).toContainText('Loading preview'); + }); + + test('should display variable selector with options from metadata', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode and wait for metadata to load + await page.locator('#btn-static').click(); + + // Wait for content to be visible + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + // Verify variable selector exists and has options + const variableSelect = page.locator('#variable-select'); + await expect(variableSelect).toBeVisible(); + + // Check that the variables from metadata are present + const options = variableSelect.locator('option'); + await expect(options).toHaveCount(3); + + // Verify first option is temperature + await expect(options.first()).toContainText('Temperature'); + await expect(options.first()).toContainText('(K)'); + }); + + test('should display time slider for temporal data', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + // Verify time slider exists + const timeSlider = page.locator('#time-slider'); + await expect(timeSlider).toBeVisible(); + + // Verify max is set correctly (time dimension - 1) + const max = await timeSlider.getAttribute('max'); + expect(parseInt(max)).toBe(ITEM_METADATA_FIXTURE.dimensions.time - 1); + + // Verify time label is visible + const timeLabel = page.locator('#time-label'); + await expect(timeLabel).toBeVisible(); + await expect(timeLabel).toHaveText('t=0'); + }); + + test('should display colormap selector with options', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + // Verify colormap selector exists + const colormapSelect = page.locator('#colormap-select'); + await expect(colormapSelect).toBeVisible(); + + // Verify standard colormap options + const options = colormapSelect.locator('option'); + await expect(options).toHaveCount(4); + + // Check specific options (use toHaveAttribute for option elements) + await expect(options.nth(0)).toHaveAttribute('value', 'viridis'); + await expect(options.nth(1)).toHaveAttribute('value', 'plasma'); + await expect(options.nth(2)).toHaveAttribute('value', 'coolwarm'); + await expect(options.nth(3)).toHaveAttribute('value', 'RdBu_r'); + }); + + test('should display refresh button', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + // Verify refresh button exists + const refreshBtn = page.locator('#refresh-btn'); + await expect(refreshBtn).toBeVisible(); + await expect(refreshBtn).toHaveText('Refresh'); + }); + + test('should update time label when slider is moved', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + const timeSlider = page.locator('#time-slider'); + const timeLabel = page.locator('#time-label'); + + // Change slider value + await timeSlider.fill('5'); + await timeSlider.dispatchEvent('input'); + + // Verify label updates + await expect(timeLabel).toHaveText('t=5'); + }); + + test('should display preview image with correct src', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + // Verify preview image exists and has correct src pattern + const previewImage = page.locator('#preview-image'); + await expect(previewImage).toBeVisible(); + + const src = await previewImage.getAttribute('src'); + expect(src).toContain(`${VIZ_SERVER_URL}/preview/${STAC_ITEM_FIXTURE.id}.png`); + expect(src).toContain('var=temperature'); + expect(src).toContain('time=0'); + expect(src).toContain('cmap=viridis'); + }); + + test('should display metadata dimensions', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + // Verify metadata dimensions are displayed + const metadataDiv = page.locator('#preview-metadata'); + await expect(metadataDiv).toBeVisible(); + await expect(metadataDiv).toContainText('Dimensions'); + await expect(metadataDiv).toContainText('time=12'); + await expect(metadataDiv).toContainText('lat=180'); + await expect(metadataDiv).toContainText('lon=360'); + }); + + test('should show error when metadata fetch fails', async ({ page }) => { + await mockVizServer(page, { metadataStatus: 500 }); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for error to appear + const errorDiv = page.locator('#static-error'); + await expect(errorDiv).toBeVisible({ timeout: 5000 }); + await expect(errorDiv).toContainText('Preview unavailable'); + }); + }); + + test.describe('Collection Preview Warning', () => { + + test('should show info message for collections in interactive mode', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE); + await page.setContent(html); + + // Verify collection warning message is shown + const infoAlert = page.locator('#interactive-preview .alert-info'); + await expect(infoAlert).toBeVisible(); + await expect(infoAlert).toContainText('Select an item from the collection to preview its data'); + }); + + test('should show warning message for collections in static mode', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Verify collection warning message is shown + const warningAlert = page.locator('#static-preview .alert-warning'); + await expect(warningAlert).toBeVisible(); + await expect(warningAlert).toContainText('Select an item from the collection to preview its data'); + }); + + test('should not show iframe for collections', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE); + await page.setContent(html); + + // Verify no iframe exists for collections + const iframe = page.locator('#preview-iframe'); + await expect(iframe).toHaveCount(0); + }); + + test('should not show preview controls for collections in static mode', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Verify preview controls are not present + const variableSelect = page.locator('#variable-select'); + const timeSlider = page.locator('#time-slider'); + const colormapSelect = page.locator('#colormap-select'); + + await expect(variableSelect).toHaveCount(0); + await expect(timeSlider).toHaveCount(0); + await expect(colormapSelect).toHaveCount(0); + }); + }); + + test.describe('Refresh Functionality', () => { + + test('should update image src with refresh parameter when refresh clicked', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + // Get initial image src + const previewImage = page.locator('#preview-image'); + const initialSrc = await previewImage.getAttribute('src'); + + // Click refresh + await page.locator('#refresh-btn').click(); + + // Verify src changed (has _refresh parameter) + const newSrc = await previewImage.getAttribute('src'); + expect(newSrc).not.toBe(initialSrc); + expect(newSrc).toContain('_refresh='); + }); + }); + + test.describe('Variable Selection', () => { + + test('should update image when different variable is selected', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + const variableSelect = page.locator('#variable-select'); + const previewImage = page.locator('#preview-image'); + + // Get initial src + const initialSrc = await previewImage.getAttribute('src'); + expect(initialSrc).toContain('var=temperature'); + + // Change variable to precipitation + await variableSelect.selectOption('precipitation'); + + // Verify image src updated + const newSrc = await previewImage.getAttribute('src'); + expect(newSrc).toContain('var=precipitation'); + }); + }); + + test.describe('Colormap Selection', () => { + + test('should update image when different colormap is selected', async ({ page }) => { + await mockVizServer(page); + + const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE); + await page.setContent(html); + + // Switch to static mode + await page.locator('#btn-static').click(); + + // Wait for content to load + const previewContent = page.locator('#preview-content'); + await expect(previewContent).toBeVisible({ timeout: 5000 }); + + const colormapSelect = page.locator('#colormap-select'); + const previewImage = page.locator('#preview-image'); + + // Get initial src + const initialSrc = await previewImage.getAttribute('src'); + expect(initialSrc).toContain('cmap=viridis'); + + // Change colormap to plasma + await colormapSelect.selectOption('plasma'); + + // Verify image src updated + const newSrc = await previewImage.getAttribute('src'); + expect(newSrc).toContain('cmap=plasma'); + }); + }); +}); diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index e7d66a5809..aad7642726 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -9,15 +9,26 @@ const API_ROOT_URL = "https://earth-search.aws.test.com/v1"; const API_COLLECTIONS_URL = "https://earth-search.aws.test.com/v1/collections"; // todo: Move STAC documents to separate files +const API_QUERYABLES_URL = "https://earth-search.aws.test.com/v1/queryables"; + const API_ROOT_FIXTURE = { stac_version: "1.0.0", id: "test-api", title: "Test API", description: "Test API root", conformsTo: [ + "https://api.stacspec.org/v1.0.0/core", "https://api.stacspec.org/v1.0.0/item-search", "https://api.stacspec.org/v1.0.0/item-search#sort", + "https://api.stacspec.org/v1.0.0/item-search#filter", // Required for CQL filters in item search "https://api.stacspec.org/v1.0.0/collection-search", + "https://api.stacspec.org/v1.0.0/collection-search#filter", // Required for CQL filters in collection search + // CQL2 conformances - required for Quick Filters to appear + "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter", + "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter", + "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", + "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json", + "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", ], links: [ { @@ -36,6 +47,12 @@ const API_ROOT_FIXTURE = { type: "application/json", href: API_COLLECTIONS_URL, }, + { + rel: "http://www.opengis.net/def/rel/ogc/1.0/queryables", + type: "application/schema+json", + title: "Queryables", + href: API_QUERYABLES_URL, + }, ], }; @@ -89,6 +106,12 @@ const API_COLLECTIONS_FIXTURE = { type: "application/json", href: API_ROOT_URL, }, + { + rel: "http://www.opengis.net/def/rel/ogc/1.0/queryables", + type: "application/schema+json", + title: "Queryables", + href: API_QUERYABLES_URL, + }, ], context: { page: 1, @@ -124,29 +147,8 @@ export const mockApiRootAndCollections = async (page) => { }); }; -export const waitForMapReady = async (page) => { - const mapViewport = page.locator(".map .ol-viewport"); - await expect(mapViewport).toBeVisible(); - - const mapCanvas = page.locator(".map .ol-viewport canvas.ol-layer"); - await expect(mapCanvas).toBeVisible(); - - return mapViewport; -}; - -export const waitForBboxInputsPopulated = async (page) => { - const westLonInput = page.getByLabel(/west longitude/i); - const southLatInput = page.getByLabel(/south latitude/i); - const eastLonInput = page.getByLabel(/east longitude/i); - const northLatInput = page.getByLabel(/north latitude/i); - - await expect(westLonInput).not.toHaveValue(""); - await expect(southLatInput).not.toHaveValue(""); - await expect(eastLonInput).not.toHaveValue(""); - await expect(northLatInput).not.toHaveValue(""); - - return { westLonInput, southLatInput, eastLonInput, northLatInput }; -}; +// NOTE: waitForMapReady and waitForBboxInputsPopulated removed +// Spatial extent filtering not used - all climate models are global export const waitForSearchPost = async (page, responseBody = null) => { let handler; diff --git a/tests/e2e/homepage.spec.js b/tests/e2e/homepage.spec.js index 021878faa2..ec91e72ddd 100644 --- a/tests/e2e/homepage.spec.js +++ b/tests/e2e/homepage.spec.js @@ -32,9 +32,9 @@ test.describe('STAC Browser Homepage', () => { const dropdownMenu = page.locator('.dropdown-menu'); await expect(dropdownMenu).toBeVisible(); - // Count the number of language options (should be 11) + // Count the number of language options (test config only has English) const languageOptions = dropdownMenu.locator('.dropdown-item'); - await expect(languageOptions).toHaveCount(11); + await expect(languageOptions).toHaveCount(1); // Verify English is visible in the list const englishOption = dropdownMenu.getByText(/english/i); diff --git a/tests/e2e/search-filter.spec.js b/tests/e2e/search-filter.spec.js new file mode 100644 index 0000000000..218df50300 --- /dev/null +++ b/tests/e2e/search-filter.spec.js @@ -0,0 +1,705 @@ +import { test, expect } from '@playwright/test'; +import { + SEARCH_PATH, + mockApiRootAndCollections, + waitForSearchPost +} from './helpers'; + +/** + * Helper to mock the queryables endpoint with climate-science relevant properties + */ +const mockQueryablesEndpoint = async (page) => { + await page.route('**/queryables', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + '$schema': 'https://json-schema.org/draft/2020-12/schema', + '$id': 'https://example.com/queryables', + type: 'object', + title: 'Queryables', + properties: { + 'nml:radctl:co2vmr': { + title: 'CO2 Volume Mixing Ratio', + type: 'number', + description: 'CO2 concentration in volume mixing ratio' + }, + 'nml:radctl:ch4vmr': { + title: 'CH4 Volume Mixing Ratio', + type: 'number', + description: 'CH4 concentration in volume mixing ratio' + }, + 'nml:radctl:n2ovmr': { + title: 'N2O Volume Mixing Ratio', + type: 'number', + description: 'N2O concentration in volume mixing ratio' + }, + 'model': { + title: 'Model Component', + type: 'string', + enum: ['echam', 'fesom', 'jsbach', 'hdmodel', 'oasis', 'recom'] + }, + 'experiment_type': { + title: 'Experiment Type', + type: 'string' + }, + 'paleo:years_bp': { + title: 'Years Before Present', + type: 'number' + } + } + }) + }); + return; + } + await route.continue(); + }); +}; + +/** + * Helper to mock paleo presets endpoint + */ +const mockPaleoPresetsEndpoint = async (page) => { + await page.route('**/paleo-presets', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + presets: [ + { id: 'lgm', name: 'LGM', display: '21.0 ka', years_bp: 21000, description: 'Last Glacial Maximum' }, + { id: 'mid_holocene', name: 'Mid-Holocene', display: '6.0 ka', years_bp: 6000, description: 'Mid-Holocene warm period' }, + { id: 'eemian', name: 'Eemian', display: '125.0 ka', years_bp: 125000, description: 'Last Interglacial' }, + { id: 'preindustrial', name: 'PI', display: '1850 CE', years_bp: 100, description: 'Pre-industrial' } + ] + }) + }); + return; + } + await route.continue(); + }); +}; + +/** + * Combined mock setup for all search filter tests + */ +const setupMocks = async (page) => { + await mockApiRootAndCollections(page); + await mockQueryablesEndpoint(page); + await mockPaleoPresetsEndpoint(page); +}; + +test.describe('SearchFilter Quick Filters UI', () => { + test('Should render quick filters section with all components', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + // Wait for the page to load + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Verify Quick Filters section is visible', async () => { + const quickFiltersLabel = page.getByText('Quick Filters'); + await expect(quickFiltersLabel).toBeVisible(); + }); + + await test.step('Verify Model Components section exists', async () => { + const modelComponentsLabel = page.getByText('Model Components'); + await expect(modelComponentsLabel).toBeVisible(); + }); + + await test.step('Verify CO2 Level inputs exist', async () => { + const co2Label = page.getByText('CO2 Level (ppm)'); + await expect(co2Label).toBeVisible(); + }); + + await test.step('Verify CH4 Level inputs exist', async () => { + const ch4Label = page.getByText('CH4 Level (ppb)'); + await expect(ch4Label).toBeVisible(); + }); + + await test.step('Verify N2O Level inputs exist', async () => { + const n2oLabel = page.getByText('N2O Level (ppb)'); + await expect(n2oLabel).toBeVisible(); + }); + + await test.step('Verify Experiment Type dropdown exists', async () => { + // Use more specific selector - the label in Quick Filters section + const experimentTypeLabel = page.locator('.quick-filters').getByText('Experiment Type'); + await expect(experimentTypeLabel).toBeVisible(); + }); + + await test.step('Verify Output Frequency dropdown exists', async () => { + const frequencyLabel = page.locator('.quick-filters').getByText('Output Frequency'); + await expect(frequencyLabel).toBeVisible(); + }); + }); + + test('Should render CO2 preset buttons', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Verify PI (284) preset button exists', async () => { + const piButton = page.getByRole('button', { name: /PI \(284\)/i }); + await expect(piButton).toBeVisible(); + }); + + await test.step('Verify 2xCO2 preset button exists', async () => { + const twoXButton = page.getByRole('button', { name: /2xCO2/i }); + await expect(twoXButton).toBeVisible(); + }); + + await test.step('Verify 4xCO2 preset button exists', async () => { + const fourXButton = page.getByRole('button', { name: /4xCO2/i }); + await expect(fourXButton).toBeVisible(); + }); + }); +}); + +test.describe('SearchFilter CO2 Preset Buttons', () => { + test('PI preset should set CO2 range to 280-290 ppm', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Click PI preset button', async () => { + const piButton = page.getByRole('button', { name: /PI \(284\)/i }); + await piButton.click(); + }); + + await test.step('Verify CO2 min/max values are set correctly', async () => { + // Find the CO2 inputs - they are in the quick-filter-row containing "CO2 Level" + const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' }); + const minInput = co2Row.locator('input[type="number"]').first(); + const maxInput = co2Row.locator('input[type="number"]').last(); + + await expect(minInput).toHaveValue('280'); + await expect(maxInput).toHaveValue('290'); + }); + }); + + test('2xCO2 preset should set CO2 range to 550-570 ppm', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Click 2xCO2 preset button', async () => { + const twoXButton = page.getByRole('button', { name: /2xCO2/i }); + await twoXButton.click(); + }); + + await test.step('Verify CO2 min/max values are set correctly', async () => { + const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' }); + const minInput = co2Row.locator('input[type="number"]').first(); + const maxInput = co2Row.locator('input[type="number"]').last(); + + await expect(minInput).toHaveValue('550'); + await expect(maxInput).toHaveValue('570'); + }); + }); + + test('4xCO2 preset should set CO2 range to 1100-1140 ppm', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Click 4xCO2 preset button', async () => { + const fourXButton = page.getByRole('button', { name: /4xCO2/i }); + await fourXButton.click(); + }); + + await test.step('Verify CO2 min/max values are set correctly', async () => { + const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' }); + const minInput = co2Row.locator('input[type="number"]').first(); + const maxInput = co2Row.locator('input[type="number"]').last(); + + await expect(minInput).toHaveValue('1100'); + await expect(maxInput).toHaveValue('1140'); + }); + }); +}); + +test.describe('SearchFilter GHG Unit Conversion', () => { + test('CO2 filter should convert ppm to decimal in CQL2 filter', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Enter CO2 range in ppm', async () => { + const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' }); + const minInput = co2Row.locator('input[type="number"]').first(); + const maxInput = co2Row.locator('input[type="number"]').last(); + + await minInput.fill('400'); + await maxInput.fill('500'); + }); + + await test.step('Submit search and verify CQL2 filter has decimal values', async () => { + const requestPromise = waitForSearchPost(page); + const submitButton = page.getByRole('button', { name: /submit/i }); + await submitButton.click(); + + const { body } = await requestPromise; + + // The filter should contain CO2 values converted from ppm to decimal + // 400 ppm = 400 * 1e-6 = 0.0004 + // 500 ppm = 500 * 1e-6 = 0.0005 + expect(body.filter).toBeDefined(); + + // Convert filter to string for easier inspection + const filterJson = JSON.stringify(body.filter); + + // Check that the CO2 field is referenced + expect(filterJson).toContain('nml:radctl:co2vmr'); + + // Check that values are in decimal format (should be small numbers like 0.0004) + // The exact structure depends on CQL2 format, but values should be < 1 + expect(filterJson).toMatch(/0\.000[0-9]/); + }); + }); + + test('CH4 filter should convert ppb to decimal in CQL2 filter', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Enter CH4 range in ppb', async () => { + const ch4Row = page.locator('.quick-filter-row').filter({ hasText: 'CH4 Level' }); + const minInput = ch4Row.locator('input[type="number"]').first(); + const maxInput = ch4Row.locator('input[type="number"]').last(); + + await minInput.fill('1800'); + await maxInput.fill('2000'); + }); + + await test.step('Submit search and verify CQL2 filter has decimal values', async () => { + const requestPromise = waitForSearchPost(page); + const submitButton = page.getByRole('button', { name: /submit/i }); + await submitButton.click(); + + const { body } = await requestPromise; + + expect(body.filter).toBeDefined(); + + const filterJson = JSON.stringify(body.filter); + + // Check that the CH4 field is referenced + expect(filterJson).toContain('nml:radctl:ch4vmr'); + + // 1800 ppb = 1800 * 1e-9 = 0.0000018 + // Values should be very small (in the e-6 range) + expect(filterJson).toMatch(/[0-9]\.[0-9]+e-[0-9]|0\.0000[0-9]/); + }); + }); + + test('N2O filter should convert ppb to decimal in CQL2 filter', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Enter N2O range in ppb', async () => { + const n2oRow = page.locator('.quick-filter-row').filter({ hasText: 'N2O Level' }); + const minInput = n2oRow.locator('input[type="number"]').first(); + const maxInput = n2oRow.locator('input[type="number"]').last(); + + await minInput.fill('300'); + await maxInput.fill('350'); + }); + + await test.step('Submit search and verify CQL2 filter has decimal values', async () => { + const requestPromise = waitForSearchPost(page); + const submitButton = page.getByRole('button', { name: /submit/i }); + await submitButton.click(); + + const { body } = await requestPromise; + + expect(body.filter).toBeDefined(); + + const filterJson = JSON.stringify(body.filter); + + // Check that the N2O field is referenced + expect(filterJson).toContain('nml:radctl:n2ovmr'); + + // 300 ppb = 300 * 1e-9 = 0.0000003 + expect(filterJson).toMatch(/[0-9]\.[0-9]+e-[0-9]|0\.0000[0-9]/); + }); + }); +}); + +test.describe('SearchFilter Paleo Presets', () => { + test('Should display paleo time period presets when available', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + // Wait for paleo presets to load + await page.waitForLoadState('networkidle'); + + await test.step('Verify Paleo Time Period label is visible', async () => { + const paleoLabel = page.getByText('Paleo Time Period'); + await expect(paleoLabel).toBeVisible(); + }); + + await test.step('Verify LGM preset button exists', async () => { + const lgmButton = page.getByRole('button', { name: /^LGM$/i }); + await expect(lgmButton).toBeVisible(); + }); + + await test.step('Verify Mid-Holocene preset button exists', async () => { + const midHoloceneButton = page.getByRole('button', { name: /Mid-Holocene/i }); + await expect(midHoloceneButton).toBeVisible(); + }); + + await test.step('Verify Eemian preset button exists', async () => { + const eemianButton = page.getByRole('button', { name: /^Eemian$/i }); + await expect(eemianButton).toBeVisible(); + }); + + await test.step('Verify PI preset button exists', async () => { + const piButton = page.getByRole('button', { name: /^PI$/i }); + await expect(piButton).toBeVisible(); + }); + }); + + test('Selecting a paleo preset should highlight the button', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await test.step('Click LGM preset and verify it becomes selected', async () => { + const lgmButton = page.getByRole('button', { name: /^LGM$/i }); + await lgmButton.click(); + + // The selected button should have variant 'info' instead of 'outline-info' + // This typically translates to a different class + await expect(lgmButton).toHaveClass(/btn-info/); + }); + + await test.step('Verify Clear button appears after selection', async () => { + const clearButton = page.getByRole('button', { name: /^Clear$/i }); + await expect(clearButton).toBeVisible(); + }); + }); + + test('Clear button should deselect paleo preset', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await test.step('Select LGM preset', async () => { + const lgmButton = page.getByRole('button', { name: /^LGM$/i }); + await lgmButton.click(); + await expect(lgmButton).toHaveClass(/btn-info/); + }); + + await test.step('Click Clear button and verify preset is deselected', async () => { + const clearButton = page.getByRole('button', { name: /^Clear$/i }); + await clearButton.click(); + + // After clearing, LGM should have outline-info variant + const lgmButton = page.getByRole('button', { name: /^LGM$/i }); + await expect(lgmButton).toHaveClass(/btn-outline-info/); + + // Clear button should be hidden + await expect(clearButton).not.toBeVisible(); + }); + }); + + test('Paleo preset selection should include years_bp filter in search', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await test.step('Select LGM preset (21000 years BP)', async () => { + const lgmButton = page.getByRole('button', { name: /^LGM$/i }); + await lgmButton.click(); + }); + + await test.step('Submit search and verify paleo filter is included', async () => { + const requestPromise = waitForSearchPost(page); + const submitButton = page.getByRole('button', { name: /submit/i }); + await submitButton.click(); + + const { body } = await requestPromise; + + expect(body.filter).toBeDefined(); + + const filterJson = JSON.stringify(body.filter); + + // Check that paleo years_bp field is referenced + expect(filterJson).toContain('paleo:years_bp'); + + // LGM is 21000 years BP, with 10% tolerance = 18900 to 23100 + // Check for values in that range + expect(filterJson).toMatch(/1[89][0-9]{3}|2[0-3][0-9]{3}/); + }); + }); +}); + +test.describe('SearchFilter Form Submission with Quick Filters', () => { + test('Search submission should include model component filters', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Select ECHAM model component', async () => { + // Model components are rendered as checkbox-group with button styling + const echamLabel = page.locator('.quick-filters').getByText('ECHAM'); + await echamLabel.click(); + }); + + await test.step('Submit search and verify model filter is included', async () => { + const requestPromise = waitForSearchPost(page); + const submitButton = page.getByRole('button', { name: /submit/i }); + await submitButton.click(); + + const { body } = await requestPromise; + + expect(body.filter).toBeDefined(); + + const filterJson = JSON.stringify(body.filter); + + // Check that model field is referenced + expect(filterJson).toContain('model'); + expect(filterJson).toContain('echam'); + }); + }); + + test('Search submission should include experiment type filter', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Select Historical experiment type', async () => { + const experimentSelect = page.locator('.quick-filter-row').filter({ hasText: 'Experiment Type' }).locator('select'); + await experimentSelect.selectOption('historical'); + }); + + await test.step('Submit search and verify experiment type filter is included', async () => { + const requestPromise = waitForSearchPost(page); + const submitButton = page.getByRole('button', { name: /submit/i }); + await submitButton.click(); + + const { body } = await requestPromise; + + expect(body.filter).toBeDefined(); + + const filterJson = JSON.stringify(body.filter); + + // Check that experiment_type is in the filter + expect(filterJson).toContain('experiment_type'); + expect(filterJson).toContain('historical'); + }); + }); + + test('Search submission should include multiple quick filters combined', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await test.step('Set multiple quick filters', async () => { + // Set CO2 range + const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' }); + await co2Row.locator('input[type="number"]').first().fill('280'); + await co2Row.locator('input[type="number"]').last().fill('300'); + + // Select model component (checkbox-group with button styling) + const fesom = page.locator('.quick-filters').getByText('FESOM'); + await fesom.click(); + + // Select paleo preset + const lgmButton = page.getByRole('button', { name: /^LGM$/i }); + await lgmButton.click(); + }); + + await test.step('Submit search and verify all filters are combined', async () => { + const requestPromise = waitForSearchPost(page); + const submitButton = page.getByRole('button', { name: /submit/i }); + await submitButton.click(); + + const { body } = await requestPromise; + + expect(body.filter).toBeDefined(); + + const filterJson = JSON.stringify(body.filter); + + // All fields should be present in the combined filter + expect(filterJson).toContain('nml:radctl:co2vmr'); + expect(filterJson).toContain('model'); + expect(filterJson).toContain('fesom'); + expect(filterJson).toContain('paleo:years_bp'); + }); + }); + + test('Reset button should clear all quick filters', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Set some quick filters', async () => { + // Set CO2 range using PI preset + const piButton = page.getByRole('button', { name: /PI \(284\)/i }); + await piButton.click(); + + // Verify values are set + const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' }); + await expect(co2Row.locator('input[type="number"]').first()).toHaveValue('280'); + }); + + await test.step('Click Reset button', async () => { + const resetButton = page.getByRole('button', { name: /reset/i }); + await resetButton.click(); + }); + + await test.step('Verify quick filters are cleared', async () => { + const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' }); + const minInput = co2Row.locator('input[type="number"]').first(); + const maxInput = co2Row.locator('input[type="number"]').last(); + + // Inputs should be empty after reset + await expect(minInput).toHaveValue(''); + await expect(maxInput).toHaveValue(''); + }); + }); +}); + +test.describe('SearchFilter Queryables Search', () => { + test('Should display Additional Filters dropdown with search input', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await test.step('Verify Additional Filters section exists', async () => { + const additionalFiltersLabel = page.getByText('Additional Filters'); + await expect(additionalFiltersLabel).toBeVisible(); + }); + + await test.step('Open Add Filter dropdown', async () => { + const addFilterButton = page.getByRole('button', { name: /add filter/i }); + await addFilterButton.click(); + + // Verify dropdown menu appears + const dropdownMenu = page.locator('.queryables-menu'); + await expect(dropdownMenu).toBeVisible(); + }); + + await test.step('Verify filter search input exists', async () => { + const searchInput = page.locator('.queryables-search input'); + await expect(searchInput).toBeVisible(); + }); + }); + + test('Queryables search should filter properties by name', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await test.step('Open Add Filter dropdown and search for CO2', async () => { + const addFilterButton = page.getByRole('button', { name: /add filter/i }); + await addFilterButton.click(); + + const searchInput = page.locator('.queryables-search input'); + await searchInput.fill('CO2'); + }); + + await test.step('Verify only CO2-related group is shown and can be expanded', async () => { + const dropdownMenu = page.locator('.queryables-menu'); + + // Should show the Radctl group (contains CO2) + const radctlGroup = dropdownMenu.locator('.queryable-group-header').filter({ hasText: 'Radctl' }); + await expect(radctlGroup).toBeVisible(); + + // Expand the group to see individual properties + await radctlGroup.click(); + + // Should show co2vmr property after expanding (visible as badge) + await expect(dropdownMenu.getByText('co2vmr')).toBeVisible(); + + // ch4vmr and n2ovmr should not be visible (filtered out by search) + await expect(dropdownMenu.getByText('ch4vmr')).not.toBeVisible(); + await expect(dropdownMenu.getByText('n2ovmr')).not.toBeVisible(); + }); + }); + + test('Searching for non-existent property should show no results message', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + await page.waitForLoadState('networkidle'); + + await test.step('Search for non-existent property', async () => { + const addFilterButton = page.getByRole('button', { name: /add filter/i }); + await addFilterButton.click(); + + const searchInput = page.locator('.queryables-search input'); + await searchInput.fill('xyz_nonexistent_property_123'); + }); + + await test.step('Verify no matching properties message is shown', async () => { + // The dropdown menu should show no groups or properties + const dropdownMenu = page.locator('.queryables-menu'); + // Match either translated text or i18n key fallback + const noResultsMessage = dropdownMenu.getByText(/no matching properties|noMatchingProperties/i); + await expect(noResultsMessage).toBeVisible(); + }); + }); +}); + +test.describe('SearchFilter Output Frequency Dropdown', () => { + test('Should display frequency options in dropdown', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Verify frequency dropdown has expected options', async () => { + const frequencySelect = page.locator('.quick-filter-row').filter({ hasText: 'Output Frequency' }).locator('select'); + + // Check select element is visible + await expect(frequencySelect).toBeVisible(); + + // Verify some key options exist + const options = frequencySelect.locator('option'); + await expect(options).toHaveCount(7); // Any Frequency + 6 time intervals + }); + }); + + test('Selecting frequency should update the dropdown value', async ({ page }) => { + await setupMocks(page); + await page.goto(SEARCH_PATH); + + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await test.step('Select Monthly frequency', async () => { + const frequencySelect = page.locator('.quick-filter-row').filter({ hasText: 'Output Frequency' }).locator('select'); + await frequencySelect.selectOption('mon'); + + await expect(frequencySelect).toHaveValue('mon'); + }); + }); +}); diff --git a/tests/e2e/searchpage.spec.js b/tests/e2e/searchpage.spec.js index 6e62c1b816..e23d2ae5d2 100644 --- a/tests/e2e/searchpage.spec.js +++ b/tests/e2e/searchpage.spec.js @@ -2,30 +2,10 @@ import { test, expect } from '@playwright/test'; import { SEARCH_PATH, mockApiRootAndCollections, - waitForMapReady, - waitForBboxInputsPopulated, waitForSearchPost } from './helpers'; -const enableSpatialExtentInputs = async (page) => { - const enableSpatialCheckbox = page.getByRole('checkbox', { name: /filter by spatial extent/i }); - await enableSpatialCheckbox.check(); - const southLatInput = page.getByLabel(/south latitude/i); - await expect(southLatInput).toBeVisible(); - return southLatInput; -}; - -const fillBboxInputs = async (page, values) => { - const westLonInput = page.getByLabel(/west longitude/i); - const southLatInput = page.getByLabel(/south latitude/i); - const eastLonInput = page.getByLabel(/east longitude/i); - const northLatInput = page.getByLabel(/north latitude/i); - - if (values.westLon != null) await westLonInput.fill(values.westLon); - if (values.southLat != null) await southLatInput.fill(values.southLat); - if (values.eastLon != null) await eastLonInput.fill(values.eastLon); - if (values.northLat != null) await northLatInput.fill(values.northLat); -}; +// NOTE: Spatial extent helpers removed - all climate models in this catalog are global test.describe('STAC Browser Search page', () => { test('Should load the Search page successfully', async ({ page }) => { @@ -81,176 +61,8 @@ test.describe('STAC Browser Search page', () => { }); }); - test('Search with spatial extent via map click should have valid POST body', async ({ page }) => { - await mockApiRootAndCollections(page); - await page.goto(SEARCH_PATH); - - await test.step('Enable spatial extent selection and click on map to create bounding box', async () => { - const enableSpatialCheckbox = page.getByRole('checkbox', { name: /filter by spatial extent/i }); - await enableSpatialCheckbox.check(); - - const mapViewport = await waitForMapReady(page); - - // Click on the map to create a bounding box - await mapViewport.click({ position: { x: 300, y: 200 } }); - - await waitForBboxInputsPopulated(page); - }); - - await test.step('Submit search and verify POST body contains a bbox', async () => { - const submitButton = page.getByRole('button', { name: /submit/i }); - const requestPromise = waitForSearchPost(page); - - await submitButton.click(); - - const { body } = await requestPromise; - expect(body.bbox).toHaveLength(4); - expect(body.bbox[0]).toBeLessThan(body.bbox[2]); - expect(body.bbox[1]).toBeLessThan(body.bbox[3]); - }); - }); - - test('Search with spatial extent selection via manual input should have valid POST body', async ({ page }) => { - await mockApiRootAndCollections(page); - await page.goto(SEARCH_PATH); - - await test.step('Enable spatial extent selection and fill in bounding box values', async () => { - const enableSpatialCheckbox = page.getByRole('checkbox', { name: /filter by spatial extent/i }); - - await enableSpatialCheckbox.check(); - - // Wait for network to be idle to ensure UI is ready - await page.waitForLoadState('networkidle'); - - // Fill in bounding box values - await page.getByLabel(/west longitude/i).fill( '-116.1' ); - await page.getByLabel(/south latitude/i).fill( '44.3' ); - await page.getByLabel(/east longitude/i).fill( '-104' ); - await page.getByLabel(/north latitude/i).fill( '49' ); - await page.getByLabel(/north latitude/i).blur(); - }); - - await test.step('Submit search and verify POST body contains correct bbox', async () => { - const submitButton = page.getByRole('button', { name: /submit/i }); - - const requestPromise = waitForSearchPost(page); - - await submitButton.click(); - - const { body } = await requestPromise; - - // use toBeCloseTo for floating point comparisons - expect(body.bbox[0]).toBeCloseTo(-116.1, 2); - expect(body.bbox[1]).toBeCloseTo(44.3, 2); - expect(body.bbox[2]).toBeCloseTo(-104, 2); - expect(body.bbox[3]).toBeCloseTo(49, 2); - }); - }); - - test('Manual spatial extent shows incomplete error', async ({ page }) => { - await mockApiRootAndCollections(page); - await page.goto(SEARCH_PATH); - - await test.step('Enter 3 of 4 bounding box values', async () => { - await enableSpatialExtentInputs(page); - // Fill page with values - await fillBboxInputs(page, { - westLon: '-116.1', - southLat: '44.3', - eastLon: '-104', - northLat: '60.0' - }); - // Remove a value - await fillBboxInputs(page, { - southLat: '' - }); - }) - - await test.step('Verify error message appears with correct text', async () => { - await page.getByLabel(/south latitude/i).blur(); - - await expect(page.getByText(/Coordinate is missing/i)).toBeVisible(); - }); - }); - - test('Manual spatial extent shows invalid latitude error', async ({ page }) => { - await mockApiRootAndCollections(page); - await page.goto(SEARCH_PATH); - - await enableSpatialExtentInputs(page); - - await fillBboxInputs(page, { - westLon: '-116.1', - southLat: '-100', - eastLon: '-104', - northLat: '49' - }); - - await page.getByLabel(/north latitude/i).blur(); - - await expect(page.getByText(/Latitude must be between -90 and 90/i)).toBeVisible(); - }); - - test('Manual spatial extent shows latitude order error', async ({ page }) => { - await mockApiRootAndCollections(page); - await page.goto(SEARCH_PATH); - - await test.step('Test south latitude > north latitude error', async () => { - await enableSpatialExtentInputs(page); - - await fillBboxInputs(page, { - westLon: '-116.1', - southLat: '49', - eastLon: '-104', - northLat: '44.3' - }); - - await page.getByLabel(/north latitude/i).blur(); - - await expect(page.getByText(/South Latitude must be less than North Latitude/i)).toBeVisible(); - }); - }); - - test('Manual spatial extent shows longitude order error when west is east of east', async ({ page }) => { - await mockApiRootAndCollections(page); - await page.goto(SEARCH_PATH); - - await test.step('Test west longitude > east longitude error (both negative)', async () => { - await enableSpatialExtentInputs(page); - - await fillBboxInputs(page, { - westLon: '-80', - southLat: '10', - eastLon: '-120', - northLat: '20' - }); - - await page.getByLabel(/north latitude/i).blur(); - - await expect(page.getByText(/West Longitude must be less than East Longitude/i)).toBeVisible(); - }); - }); - - test('Manual spatial extent allows antimeridian crossing (positive west, negative east)', async ({ page }) => { - await mockApiRootAndCollections(page); - await page.goto(SEARCH_PATH); - - await test.step('Allow west > east when crossing antimeridian', async () => { - await enableSpatialExtentInputs(page); - - await fillBboxInputs(page, { - westLon: '170', - southLat: '-10', - eastLon: '-170', - northLat: '10' - }); - - await page.getByLabel(/north latitude/i).blur(); - - await expect(page.getByText(/West Longitude must be less than East Longitude/i)).toHaveCount(0); - }); - }); - + // NOTE: Spatial extent tests removed - all climate models in this catalog are global + // Spatial filtering may be re-added if paleoclimate proxies are included test('Search with Collection ID should have valid POST body', async ({ page }) => { await mockApiRootAndCollections(page); diff --git a/vite.config.js b/vite.config.js index b16c053bf2..d0a05ddce2 100644 --- a/vite.config.js +++ b/vite.config.js @@ -34,6 +34,7 @@ const optionsForType = (type) => const env = yargs() .parserConfiguration({ "camel-case-expansion": false }) .env("SB") + .option("CONFIG", { type: "string", description: "Path to config file" }) .boolean(optionsForType("boolean")) .number(optionsForType("number").concat(optionsForType("integer"))) .array(optionsForType("array")) @@ -47,7 +48,8 @@ delete env._; delete env.$0; // For config.js, you need to use dynamic import -const configFilePath = "file://" + path.resolve(env.CONFIG ? env.CONFIG : "./config.js"); +// Note: yargs lowercases option names, so SB_CONFIG becomes env.config +const configFilePath = "file://" + path.resolve(env.config ? env.config : "./config.js"); // Note: This makes the config async - you'll need to handle this let configFromFile; @@ -124,6 +126,7 @@ export default defineConfig(({ mode }) => ({ BFormInvalidFeedback: true, BFormSelect: true, BFormCheckbox: true, + BFormCheckboxGroup: true, BFormRadio: true, BFormRadioGroup: true, BInputGroup: true, From eb08f982eca2146b19c3326b63ff57d6fe2cf83f Mon Sep 17 00:00:00 2001 From: Paul Gierz Date: Sun, 15 Mar 2026 01:29:12 -0700 Subject: [PATCH 09/40] fix: update interactive preview URL to /preview/{item_id}/panel - Frontend now uses RESTful URL pattern for Panel interactive preview - Update e2e test mocks to match new URL scheme --- src/components/DataPreview.vue | 5 +---- tests/e2e/data-preview.spec.js | 11 +++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/components/DataPreview.vue b/src/components/DataPreview.vue index 1cd12bb21b..11f7fd2ba8 100644 --- a/src/components/DataPreview.vue +++ b/src/components/DataPreview.vue @@ -156,16 +156,13 @@ export default defineComponent({ }, interactiveUrl() { if (!this.item?.id || this.isCollection) return null; - // Panel app is served at /panel/ with query parameters - // item_id is passed as a query param, not a path segment const params = new URLSearchParams({ - item_id: this.item.id, stac_api: this.stacApi }); if (this.collectionId) { params.set('collection_id', this.collectionId); } - return `${this.vizServer}/panel/?${params}`; + return `${this.vizServer}/preview/${encodeURIComponent(this.item.id)}/panel?${params}`; }, staticPreviewUrl() { if (!this.selectedVariable || !this.item?.id || this.isCollection) return null; diff --git a/tests/e2e/data-preview.spec.js b/tests/e2e/data-preview.spec.js index cc62435cc9..583bd866b3 100644 --- a/tests/e2e/data-preview.spec.js +++ b/tests/e2e/data-preview.spec.js @@ -107,8 +107,8 @@ const mockVizServer = async (page, options = {}) => { }); }); - // Mock the Panel iframe endpoint - await page.route(`${VIZ_SERVER_URL}/panel/**`, async (route) => { + // Mock the Panel interactive endpoint (/preview/*/panel redirects to /_panel/) + await page.route(`${VIZ_SERVER_URL}/preview/*/panel**`, async (route) => { await route.fulfill({ status: 200, contentType: 'text/html', @@ -165,7 +165,7 @@ const createDataPreviewTestPage = (item, options = {}) => {