Skip to content

NLS demo UX improvements #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: natural-language-search-demo-devseed
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/components/Map.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<l-tile-layer v-for="xyz of xyzLinks" ref="xyzOverlays" :key="xyz.url" layerType="overlay" v-bind="xyz" />
<LWMSTileLayer v-for="wms of wmsLinks" ref="wmsOverlays" :key="wms.url" layerType="overlay" v-bind="wms" />
<l-geo-json v-if="geojson" ref="geojson" :geojson="geojson" :options="{onEachFeature: showPopup}" :optionsStyle="{color: secondaryColor, weight: secondaryWeight}" />
<l-geo-json v-if="intersectsPolygon" ref="intersectsLayer" :geojson="intersectsPolygon" :options="{onEachFeature: showIntersectsPopup}" :optionsStyle="{color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.15, opacity: 0.8, dashArray: '5, 5'}" />
<l-geo-json v-if="intersectsPolygon" ref="intersectsLayer" :geojson="intersectsPolygon" :options="{onEachFeature: showIntersectsPopup}" :optionsStyle="{color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.15, opacity: 0.8, dashArray: '5, 5'}" @ready="onIntersectsLayerReady" />
</l-map>
<b-popover
v-if="popover && selectedItem" placement="left" triggers="manual" :show="selectedItem !== null"
Expand Down Expand Up @@ -438,6 +438,12 @@ export default {
this.$refs.geojson.mapObject.bringToFront();
}
},
onIntersectsLayerReady() {
const layer = this.$refs.intersectsLayer && this.$refs.intersectsLayer.mapObject;
if (layer && this.map) {
this.fitBounds(layer);
}
},
fitBounds(layer, noPadding = false) {
let fitOptions = {
padding: noPadding ? [0,0] : [90,90],
Expand Down
135 changes: 124 additions & 11 deletions src/components/SearchFilter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,39 @@
<b-alert v-if="naturalLanguageError" variant="danger" show dismissible @dismissed="naturalLanguageError = null">
{{ naturalLanguageError }}
</b-alert>
<div class="d-flex">
<div>
<b-form-input
:id="ids.naturalLanguage"
v-model="naturalLanguageQuery"
type="text"
:placeholder="$t('search.enterNaturalLanguageQuery')"
@keyup.enter="applyNaturalLanguageQuery"
@keyup.enter="directNaturalLanguageSearch"
@keydown.enter.prevent
class="flex-grow-1 mr-2"
class="mb-3"
/>
<b-button variant="primary" @click="applyNaturalLanguageQuery" :disabled="naturalLanguageLoading">
<span v-if="naturalLanguageLoading">
{{ $t('search.processing') }}
</span>
<span v-else>
{{ $t('search.applyNaturalLanguageQuery') }}
</span>
</b-button>
<div class="d-flex justify-content-end">
<b-button variant="outline-secondary" @click="applyNaturalLanguageQuery" :disabled="naturalLanguageLoading && naturalLanguageAction !== 'populate'" class="mr-2">
<span v-if="naturalLanguageLoading && naturalLanguageAction === 'populate'">
{{ $t('search.processing') }}
</span>
<span v-else>
{{ $t('search.populateForm') }}
</span>
</b-button>
<b-button variant="primary" @click="directNaturalLanguageSearch" :disabled="naturalLanguageLoading && naturalLanguageAction !== 'search'">
<span v-if="naturalLanguageLoading && naturalLanguageAction === 'search'">
{{ $t('search.processing') }}
</span>
<span v-else>
{{ $t('search.searchDirectly') }}
</span>
</b-button>
</div>
</div>
</b-form-group>

<hr v-if="canSupportNaturalLanguage">

<b-form-group v-if="canFilterFreeText" class="filter-freetext" :label="$t('search.freeText')" :label-for="ids.q" :description="$t('search.freeTextDescription')">
<multiselect
:id="ids.q" :value="query.q" @input="setSearchTerms"
Expand All @@ -58,6 +70,13 @@
<b-form-group v-if="canFilterExtents" class="filter-bbox" :label="$t('search.spatialExtent')" :label-for="ids.bbox">
<b-form-radio-group :id="ids.bbox" v-model="spatialExtentType" :options="spatialExtentOptions" name="spatialExtent" @change="setBBox()" />
<Map class="mb-4" v-if="spatialExtentType === 'boundingBox'" :stac="stac" selectBounds @bounds="setBBox" scrollWheelZoom />
<div v-if="spatialExtentType === 'naturalSearchArea' && naturalSearchArea">
<div class="alert alert-info mb-3">
<strong>{{ $t('search.spatialExtentOptions.naturalSearchArea') }}:</strong>
{{ $t('search.naturalSearchAreaDescription') }}
</div>
<Map class="mb-4" :key="'naturalSearchArea-' + JSON.stringify(naturalSearchArea)" :stac="stac" :stacLayerData="{intersects: naturalSearchArea}" scrollWheelZoom />
</div>
</b-form-group>

<b-form-group v-if="conformances.CollectionIdFilter" class="filter-collection" :label="$tc('stacCollection', collections.length)" :label-for="ids.collections">
Expand Down Expand Up @@ -192,6 +211,7 @@ function getDefaults() {
naturalLanguageExplanation: '',
naturalLanguageLoading: false,
naturalLanguageError: null,
naturalLanguageAction: null,
naturalSearchArea: null
};
}
Expand Down Expand Up @@ -417,6 +437,7 @@ export default {

if (this.naturalLanguageQuery) {
this.naturalLanguageLoading = true;
this.naturalLanguageAction = 'populate';
try {
const SEMANTIC_SEARCH_API_URL = this.$store.state.semanticSearchApiUrl;
const response = await fetch(`${SEMANTIC_SEARCH_API_URL}/items/search`, {
Expand Down Expand Up @@ -506,6 +527,97 @@ export default {
this.naturalLanguageError = error.message;
} finally {
this.naturalLanguageLoading = false;
this.naturalLanguageAction = null;
}
}
},
async directNaturalLanguageSearch(event) {
// Prevent form submission
if (event) {
event.preventDefault();
event.stopPropagation();
}

if (this.naturalLanguageQuery) {
this.naturalLanguageLoading = true;
this.naturalLanguageAction = 'search';
try {
const SEMANTIC_SEARCH_API_URL = this.$store.state.semanticSearchApiUrl;
const response = await fetch(`${SEMANTIC_SEARCH_API_URL}/items/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
return_search_params_only: true,
query: this.naturalLanguageQuery,
limit: this.query.limit || this.itemsPerPage
})
});

if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}

const responseData = await response.json();

// Create a search query from the response and emit it directly
const searchQuery = {};

if (responseData.results && responseData.results.search_params) {
const params = responseData.results.search_params;

// Extract datetime
if (params.datetime) {
const datetimeString = params.datetime;
const dates = datetimeString.split('/');
if (dates.length === 2) {
const startDate = dates[0] === '..' ? null : new Date(dates[0]);
const endDate = dates[1] === '..' ? null : new Date(dates[1]);
searchQuery.datetime = [startDate, endDate];
}
}

// Extract collections
if (params.collections && Array.isArray(params.collections)) {
searchQuery.collections = [...new Set(params.collections)];
}

// Extract limit
if (params.max_items) {
const maxItems = parseInt(params.max_items, 10);
if (!isNaN(maxItems) && maxItems > 0) {
searchQuery.limit = Math.min(maxItems, this.maxItems);
}
}

// Extract spatial parameters
if (params.intersects) {
searchQuery.intersects = params.intersects;
// Also store the natural search area locally for UI display
this.naturalSearchArea = params.intersects;
this.spatialExtentType = 'naturalSearchArea';
}
}

// Store explanation for display
if (responseData.explanation) {
this.naturalLanguageExplanation = responseData.explanation;
} else if (responseData.results && responseData.results.explanation) {
this.naturalLanguageExplanation = responseData.results.explanation;
}

// Emit the search query directly to trigger immediate search
this.$emit('input', searchQuery, false);

this.naturalLanguageError = null;

} catch (error) {
console.error('Error in direct semantic search:', error);
this.naturalLanguageError = error.message;
} finally {
this.naturalLanguageLoading = false;
this.naturalLanguageAction = null;
}
}
},
Expand Down Expand Up @@ -693,6 +805,7 @@ export default {
this.naturalLanguageExplanation = '';
this.naturalLanguageLoading = false;
this.naturalLanguageError = null;
this.naturalLanguageAction = null;
this.naturalSearchArea = null;
this.query.intersects = null;
this.$emit('input', {}, true);
Expand Down
5 changes: 4 additions & 1 deletion src/locales/en/texts.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,10 @@
"enterSearchTerms": "Enter one or more search terms...",
"enterNaturalLanguageQuery": "e.g., 'satellite images of California from 2023'",
"naturalLanguageQuery": "Natural Language Search",
"naturalLanguageInfo": "Enter a natural language query to automatically populate the search form fields. Then click 'Submit' to execute the search with the populated criteria.",
"naturalLanguageInfo": "Enter a description of what you are searching for using natural language, such as 'satellite images of California from 2023'.",
"applyNaturalLanguageQuery": "Populate",
"searchDirectly": "Search",
"populateForm": "Populate Form",
"naturalLanguageSearchArea": "Natural Language Search Area",
"naturalLanguageSearchAreaDescription": "This area represents the spatial extent used in your natural language search query.",
"processing": "Processing...",
Expand Down Expand Up @@ -271,6 +273,7 @@
"boundingBox": "Bounding Box",
"naturalSearchArea": "Natural Search Area"
},
"naturalSearchAreaDescription": "This spatial area was automatically derived from your natural language query and will be used to filter search results.",
"tabs": {
"collections": "Search for Collections",
"items": "Search for Items"
Expand Down