diff --git a/.env b/.env index ce7e2b852..47e55ab6a 100644 --- a/.env +++ b/.env @@ -4,6 +4,7 @@ APP_CONTACT_EMAIL=email@example.org API_RASTER_ENDPOINT='https://staging.openveda.cloud/api/raster' API_STAC_ENDPOINT='https://staging.openveda.cloud/api/stac' +GEO_COPILOT_ENDPOINT=https://veda-search-poc.azurewebsites.net/score # If the app is being served in from a subfolder, the domain url must be set. # For example, if the app is served from /mysite: @@ -13,4 +14,3 @@ API_STAC_ENDPOINT='https://staging.openveda.cloud/api/stac' GOOGLE_FORM = 'https://docs.google.com/forms/d/e/1FAIpQLSfGcd3FDsM3kQIOVKjzdPn4f88hX8RZ4Qef7qBsTtDqxjTSkg/viewform?embedded=true' SHOW_CONFIGURABLE_COLOR_MAP = 'TRUE' - diff --git a/.stylelintrc.json b/.stylelintrc.json index 4397871d1..919063546 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -21,4 +21,4 @@ "app/scripts/styles/continuum/utils.ts", "**/*.d.ts" ] -} \ No newline at end of file +} diff --git a/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx b/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx index 9d5a5ae18..61a08f4f8 100644 --- a/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx +++ b/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx @@ -220,6 +220,7 @@ function CustomAoI({ // selected, the trash method doesn't do anything. So, in this case, we // trigger the delete for the whole feature. const selectedFeatures = mbDraw.getSelected()?.features; + if ( mbDraw.getMode() === DIRECT_SELECT && selectedFeatures.length && diff --git a/app/scripts/components/exploration/components/geo-copilot/geo-copilot-control.tsx b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-control.tsx new file mode 100644 index 000000000..6b78bcd92 --- /dev/null +++ b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-control.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import {faRobot} from '@fortawesome/free-solid-svg-icons'; + +import useMaps from '$components/common/map/hooks/use-maps'; +import { SelectorButton } from '$components/common/map/style/button'; +import useThemedControl from '$components/common/map/controls/hooks/use-themed-control'; + +interface GeoCoPilotControlProps { + showGeoCoPilot: () => void; + setMap: (any) => void; +} + +export function GeoCoPilotComponent({onClick}: { + onClick: (event: React.MouseEvent) => void; +}) { + return ( + + + + ); +} + +export function GeoCoPilotControl(props: GeoCoPilotControlProps) { + const {showGeoCoPilot, setMap} = props; + // Show conversation modal + const {main} = useMaps(); + setMap(main); + + useThemedControl(() => , { + position: 'top-right' + }); + return null; +} diff --git a/app/scripts/components/exploration/components/geo-copilot/geo-copilot-interaction.ts b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-interaction.ts new file mode 100644 index 000000000..917efcffa --- /dev/null +++ b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-interaction.ts @@ -0,0 +1,68 @@ +import axios from 'axios'; + +interface GeoCoPilotInteractionQuery { + question: string; + chat_history: any; + content: any; +} + +const GEOCOPILOT_ENDPOINT = process.env.GEO_COPILOT_ENDPOINT; + +const ERROR_RESPONSE = { + "dataset_ids": [], + "summary": "An unexpected error occurred with this request. Please ask another question.", + "date_range": {'start_date': '', 'end_date': ''}, + "bbox":{}, + "action": "error", + "explanation": + { + "validation": "", + "verification":[] + }, + "query": '' +}; + +/** + * Gets the asset urls for all datasets in the results of a STAC search given by + * the input parameters. + * + * @param params Dataset search request parameters + * @param opts Options for the request (see Axios) + * @returns Promise with the asset urls + */ +export async function askGeoCoPilot( + { + question, + chat_history, + content + }: GeoCoPilotInteractionQuery, + setSystemResponse: (answer: any, content: any) => void +){ + ERROR_RESPONSE['query'] = question; + + if (!GEOCOPILOT_ENDPOINT) { + setSystemResponse(ERROR_RESPONSE, content); + return; + } + + await axios.post( + GEOCOPILOT_ENDPOINT, + { + 'question': question, + 'chat_history': chat_history + } + ).then((answer) => { + const extractedAnswer = JSON.parse(answer.data.answer); + ERROR_RESPONSE['summary'] = extractedAnswer['summary'] || ERROR_RESPONSE['summary'] + if (extractedAnswer.explanation?.verification) + content[content.length - 1].explanations = extractedAnswer.explanation.verification; + setSystemResponse(JSON.parse(answer.data.answer), content); + }).catch((error) => { + setSystemResponse(ERROR_RESPONSE, content); + }); +} + + +// Returns the full geolocation url based on centroid (lat, lon) and mapboxaccesstoken +export const geolocationUrl = (centroid, mapboxAccessToken) => + `https://api.mapbox.com/geocoding/v5/mapbox.places/${centroid[0]},${centroid[1]}.json?access_token=${mapboxAccessToken}`; diff --git a/app/scripts/components/exploration/components/geo-copilot/geo-copilot-system-dialog.tsx b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-system-dialog.tsx new file mode 100644 index 000000000..be8f66b62 --- /dev/null +++ b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-system-dialog.tsx @@ -0,0 +1,189 @@ +import React, {useEffect, useState} from 'react'; +import axios from 'axios'; + + +import { Button } from '@devseed-ui/button'; +import { + CollecticonHandThumbsUp, + CollecticonHandThumbsDown, + CollecticonLink, + CollecticonChevronUpTrailSmall, + CollecticonChevronDownTrailSmall, + CollecticonCalendarRange, + CollecticonMarker, + CollecticonMap, + +} from '@devseed-ui/collecticons'; + +import centroid from '@turf/centroid'; +import { AllGeoJSON, Feature, Polygon } from '@turf/helpers'; + +import styled from 'styled-components'; +import { geolocationUrl } from './geo-copilot-interaction'; + +const DialogContent = styled.div` + width: fit-content; + max-width: 75%; + min-width: 25%; + background: white; + padding: 1em; + margin: 1em 0 1em 1em; + margin-right: auto; + border-radius: 10px; +`; + +const DialogInteraction = styled.div` + font-size: 0.6rem; + display: flex; +`; + +const ButtonContent = styled.span` + font-size: 0.6rem; +`; + +const ShowHideDetail = styled.div` + margin-left: auto; +`; + +const AnswerDetails = styled.div` + font-size: 0.6rem; + padding: 2em; + background: #f6f7f8; + border-radius: 10px; +`; + +const AnswerDetailsIcon = styled.div` + display: flex; + align-items: center; + + span { + margin-left: 4px; + } +`; + +const AnswerDetailsItem = styled.div` + margin-bottom: 6px; + + p { + font-size: 0.7rem; + margin-left: 12px; + } +`; + +export interface GeoCoPilotModalProps { + summary: string; + dataset_ids: any; + bbox: any; + date_range: any; + explanation: any; +} + +export function GeoCoPilotSystemDialogComponent({summary, dataset_ids, bbox, dateRange, explanation}: { + summary: string; + dataset_ids: any; + bbox: any; + dateRange: any; + explanation: any; +}) { + const [showDetails, setShowDetails] = useState(false); + const [location, setLocation] = useState(""); + + useEffect(() => { + const fetchGeolocation = async (center) => { + try { + const response = await axios.get(geolocationUrl(center, process.env.MAPBOX_TOKEN)); + setLocation(response.data.features[2].place_name); // assuming 'features' is the array in the API response + } catch (error) { + console.error("Reverse geocoding failed.", error); + } + }; + + if (Object.keys(bbox).length > 0) { + bbox.features = bbox.features as Feature[]; + const center = centroid(bbox as AllGeoJSON).geometry.coordinates; + console.log(bbox); + fetchGeolocation(center); + } + }, [bbox]); + + const updateShowDetails = () => { + setShowDetails(!showDetails); + }; + + const copyURL = () => { + navigator.clipboard.writeText(document.URL); + }; + + return ( + +
{summary}
+ {/*Content*/} + {explanation && + +
+ + +
| + {/*Interaction*/} +
+ +
+ {/*Summary*/} + + + +
} + {showDetails && + + + + Location + +

{location}

+
+ + + Timeframe + +

{`${dateRange.start_date} > ${dateRange.end_date}`}

+
+ + + Map layers + +

{dataset_ids.join(", ")}

+
+
} +
+ ); +} + +export function GeoCoPilotSystemDialog(props: GeoCoPilotModalProps) { + const {summary, dataset_ids, bbox, date_range, explanation} = props; + return ( + + ); +} diff --git a/app/scripts/components/exploration/components/geo-copilot/geo-copilot-user-dialog.tsx b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-user-dialog.tsx new file mode 100644 index 000000000..3a95f361c --- /dev/null +++ b/app/scripts/components/exploration/components/geo-copilot/geo-copilot-user-dialog.tsx @@ -0,0 +1,176 @@ +import React from 'react'; + +import styled from 'styled-components'; + +import JsxParser from 'react-jsx-parser'; + +const DialogContent = styled.div` + min-width: 25%; + max-width: 75%; + width: fit-content; + margin: 1em 1em 1em 0; + margin-left: auto; + background: #d5ecfb; + padding: 1em; + border-radius: 10px; + justify-content: flex-end; + display: inline-block; + position: relative; + + &.active::after { + content: attr(data-tooltip); + position: absolute; + background-color: #333; + color: #fff; + padding: 5px; + border-radius: 5px; + bottom: 100%; + right: 0; + white-space: nowrap; + z-index: 100; + opacity: 0.9; + font-size: 12px; + max-width: 150px; + text-wrap: balance; + } +`; + +const Query = styled.span` + position: relative; + cursor: pointer; + display: inline-block; + padding-bottom: 1px; + // border-bottom: 1px solid; + + &[data-explanation-index='0'] { + // text-decoration: underline; + text-decoration-color: blue; + color: blue; + } + + &[data-explanation-index='1'] { + // text-decoration: underline; + // text-decoration-color: red; + color: red; + } + + &[data-explanation-index='2'] { + // text-decoration: underline; + // text-decoration-color: black; + color: green; + } + + &[data-explanation-index='3'] { + // text-decoration: underline; + // text-decoration-color: orange; + color: orange; + } + + &[data-explanation-index='4'] { + // text-decoration: underline; + // text-decoration-color: yellow; + color: yellow; + } + + &[data-explanation-index='5'] { + // text-decoration: underline; + // text-decoration-color: pink; + // border-bottom-color: pink; + color: pink; + } +`; + +interface GeoCoPilotModalProps { + explanations: any; + query: string; +} + +export function GeoCoPilotUserDialogComponent({explanations, query}: { + explanations: any; + query: string; +}) { + // Function to dynamically split the query and insert Query parts + const renderHighlightedQuery = (query: string, explanations: any) => { + const lowerQuery: string = query.toLowerCase(); + const elementsToRender: string[] = lowerQuery.toLowerCase().split(' '); + + explanations.forEach(({ query_part, matchup }, internalIndex) => { + const index = query.indexOf(query_part.toLowerCase()); + if (index < 0) return; + const splits = query_part.split(' '); + const lastWord = splits.at(-1) || ''; + const firstWord = splits[0] || ''; + const firstWordIndex = elementsToRender.indexOf(firstWord.toLowerCase()); + if (firstWordIndex < 0) return; + elementsToRender.splice( + firstWordIndex, + 0, + `` + ); + const lastWordIndex = elementsToRender.indexOf(lastWord.toLowerCase()); + if (lastWordIndex > 0) { + elementsToRender.splice(lastWordIndex + 1, 0, ''); + } + }); + let joinedElements = elementsToRender.join(' '); + const startingQueryCount = (joinedElements.match(/ { + e.currentTarget.setAttribute('data-tooltip', ''); + e.currentTarget.classList.remove('active'); + }; + + const handleEnter = (e) => { + if (e.target) { + const toolTipValue = e.target.attributes['data-tooltip']?.value; + if (toolTipValue) { + e.currentTarget.setAttribute('data-tooltip', toolTipValue); + e.currentTarget.classList.add('active'); + } + else resetToolTip(e); + } + }; + + const handleExit = (e) => { + if (e.currentTarget) { + resetToolTip(e); + } + }; + + return ( + <> + { + (query.length) && + + {explanations.length ? + : + query} + + } + + ); +} + +export function GeoCoPilotUserDialog(props: GeoCoPilotModalProps) { + const {query, explanations} = props; + return ( + + ); +} diff --git a/app/scripts/components/exploration/components/geo-copilot/geo-copilot.tsx b/app/scripts/components/exploration/components/geo-copilot/geo-copilot.tsx new file mode 100644 index 000000000..96d3ccde2 --- /dev/null +++ b/app/scripts/components/exploration/components/geo-copilot/geo-copilot.tsx @@ -0,0 +1,423 @@ +import React, {useState, useRef, useEffect, CSSProperties} from 'react'; + +import styled from 'styled-components'; +import { themeVal, glsp } from '@devseed-ui/theme-provider'; + +import { Button } from '@devseed-ui/button'; +import { FormInput } from '@devseed-ui/form'; +import { + CollecticonChevronRightTrailSmall, + CollecticonArrowLoop, + CollecticonXmarkSmall +} from '@devseed-ui/collecticons'; + +import PulseLoader from "react-spinners/PulseLoader"; + +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; + +import bbox from '@turf/bbox'; +import centroid from '@turf/centroid'; +import { AllGeoJSON } from '@turf/helpers'; + +import { TemporalExtent } from '../timeline/timeline-utils'; + +import { GeoCoPilotSystemDialog } from './geo-copilot-system-dialog'; +import { GeoCoPilotUserDialog } from './geo-copilot-user-dialog'; +import { askGeoCoPilot } from './geo-copilot-interaction'; + +import { datasetLayers} from '$components/exploration/data-utils'; +import { makeFeatureCollection } from '$components/common/aoi/utils'; +import { getZoomFromBbox } from '$components/common/map/utils'; + +import { + TimelineDataset, + TimeDensity, + TimelineDatasetSuccess, + DatasetStatus +} from '$components/exploration/types.d.ts'; + +import { + getLowestCommonTimeDensity, + reconcileDatasets +} from '$components/exploration/data-utils-no-faux-module'; + +import { + aoiDeleteAllAtom, + aoisUpdateGeometryAtom +} from '$components/common/map/controls/aoi/atoms'; +import { selectedIntervalAtom } from '$components/exploration/atoms/dates'; + +import { useAnalysisController } from '$components/exploration/hooks/use-analysis-data-request'; + +import { SIMPLE_SELECT } from '$components/common/map/controls/aoi/index'; +import useAois from '$components/common/map/controls/hooks/use-aois'; + +import { RIGHT_AXIS_SPACE, HEADER_COLUMN_WIDTH } from '$components/exploration/constants'; +import { timelineWidthAtom } from '$components/exploration/atoms/timeline'; +import { useScales } from '$components/exploration/hooks/scales-hooks'; +import { useOnTOIZoom } from '$components/exploration/hooks/use-toi-zoom'; + +interface GeoCoPilotModalProps { + close: () => void; + datasets: TimelineDataset[]; + setDatasets: (datasets: TimelineDataset[]) => void; + setSelectedDay: (d: Date | null) => void; + setSelectedCompareDay: (d: Date | null) => void; + map: any; + setStartEndDates: (startEndDates: TemporalExtent) => void; + setTimeDensity: (timeDensity: TimeDensity) => void; +} + +const GeoCoPilotWrapper = styled.div` + padding-bottom: ${glsp()}; + height: calc(100% - 10px); + width: 100%; + background: #f6f7f8; + position: relative; +`; + +const GeoCoPilotContent = styled.div` + width: 100%; + height: calc(100% - 80px); + overflow-y: auto; + flex-direction: column; + font-size: 12px; + display: flex; +`; + +const GeoCoPilotQueryWrapper = styled.div` + display: flex; + overflow: hidden; + width: 95%; + margin: auto; + background-color: ${themeVal('color.surface')}; + + > button { + margin-left: -35px; + } +`; + +const GeoCoPilotQuery = styled(FormInput)` + width: 100%; + padding-right: 35px; + &:focus-within { + border-radius: ${themeVal('shape.rounded')}; + outline-width: 0.25rem; + outline-color: ${themeVal('color.primary-200a')}; + outline-style: solid; + } +`; + +const GeoCoPilotTitleWrapper = styled.div` + background: white; + padding: 10px; + height: 50px; + box-shadow: 0 2px 4px #b1b1b1; + margin-bottom: 3px; + display: flex; +`; + +const GeoCoPilotTitle = styled.strong` + color: #2276ad; + width: 210px; + margin: auto; +`; + +const RestartSession = styled(Button)` + align-self: flex-end; + background: #2276ad; + margin: auto; + color: white; +`; + +const CloseSession = styled(Button)` + align-self: flex-end; +`; + +const override: CSSProperties = { + display: "block", + margin: "1em auto 1em 1em", + borderColor: "red", +}; + + +export function GeoCoPilotComponent({ + close, + datasets, + setDatasets, + setSelectedDay, + setSelectedCompareDay, + map, + setStartEndDates, + setTimeDensity +}: { + close: () => void; + datasets: TimelineDataset[]; + setDatasets: (datasets: TimelineDataset[]) => void; + setSelectedDay: (d: Date | null) => void; + setSelectedCompareDay: (d: Date | null) => void; + map: any; + setStartEndDates: (startEndDates: TemporalExtent) => void; + setTimeDensity: (timeDensity: TimeDensity) => void; +}) { + const defaultSystemComment = { + summary: "Welcome to Earth Copilot! I'm here to assist you with identifying datasets with location and date information. Whether you're analyzing time-sensitive trends or working with geospatial data, I've got you covered. Let me know how I can assist you today!", + dataset_ids: [], + bbox: {}, + dateRange: {}, + date: new Date(), + action: '', + explanation: null, + query: '', + contentType: 'system' + }; + + const [conversation, setConversation] = useState([defaultSystemComment]); + const [query, setQuery] = useState(''); + const phantomElementRef = useRef(null); + const [loading, setLoading] = useState(false); + + const [selectedInterval, setSelectedInterval] = useAtom(selectedIntervalAtom); + const aoiDeleteAll = useSetAtom(aoiDeleteAllAtom); + + const { onUpdate } = useAois(); + const { cancelAnalysis, runAnalysis } = useAnalysisController(); + + const scrollToBottom = () => { + const phantomElement = phantomElementRef.current; + if (!phantomElement) return; + phantomElement.scrollIntoView({ behavior: "smooth" }); + }; + + const loadInMap = (answer: any) => { + const bounds = bbox(answer.bbox); + const center = centroid(answer.bbox as AllGeoJSON).geometry.coordinates; + + map.flyTo({ + center, + zoom: getZoomFromBbox(bounds) + }); + return answer.bbox; + }; + + const aoisUpdateGeometry = useSetAtom(aoisUpdateGeometryAtom); + + const timelineWidth = useAtomValue(timelineWidthAtom); + const { main } = useScales(); + const { onTOIZoom } = useOnTOIZoom(); + + const interval = useAtomValue(selectedIntervalAtom); + + useEffect(() => { + scrollToBottom(); + }, [loading]); + + useEffect(() => { + // Fit TOI only after datasets are available + // way to do this is by using useeffect for datasets and aoi atom then checking for missing values. + if(!main || !timelineWidth || datasets?.length == 0 || !interval?.end) + return; + + const widthToFit = (timelineWidth - RIGHT_AXIS_SPACE - HEADER_COLUMN_WIDTH) * 0.9; + const startPoint = 0; + const new_k = widthToFit/(main(interval.end) - main(interval.start)); + const new_x = startPoint - new_k * main(interval.start); + + onTOIZoom(new_x, new_k); + }, [datasets, interval]); + + const addSystemResponse = (answer: any, content: any) => { + const action = answer['action']; + const startDate = new Date(answer['date_range']['start_date']); + const endDate = new Date(answer['date_range']['end_date']); + let newDatasetIds = answer['dataset_ids'].reduce((layerIds, collectionId) => { + const foundDataset = datasetLayers.find((dataset) => dataset.stacCol == collectionId); + if (foundDataset) { + layerIds.push(foundDataset.id); + } + return layerIds; + }, []); + + newDatasetIds = newDatasetIds.filter((datasetId, index, internalArray) => + internalArray.indexOf(datasetId) === index + ); + + const newDatasets = reconcileDatasets(newDatasetIds, datasetLayers, datasets); + const mbDraw = map?._drawControl; + + answer['contentType'] = 'system'; + + mbDraw.deleteAll(); + aoiDeleteAll(); + + setDatasets(newDatasets); + try { + switch(action) { + case 'load': { + loadInMap(answer); + setSelectedCompareDay(null); + setSelectedDay(endDate); + break; + } + case 'compare': { + cancelAnalysis(); + + loadInMap(answer); + setSelectedDay(startDate); + + // hacky way of handling enddate issues (probably better to use useeffect) + setTimeout(() => { + setSelectedCompareDay(endDate); + }, 1000); + break; + } + case 'statistics': { + const geojson = loadInMap(answer); + + const updatedGeojson = makeFeatureCollection( + geojson.features.map((f, i) => ({ + id: `${new Date().getTime().toString().slice(-4)}${i}`, ...f + })) + ); + + setSelectedCompareDay(null); + + setSelectedInterval({ + start: startDate, end: endDate + }); + + onUpdate(updatedGeojson); + + aoisUpdateGeometry(updatedGeojson.features); + setStartEndDates([startDate, endDate]); + const pids = mbDraw.add(updatedGeojson); + + mbDraw.changeMode(SIMPLE_SELECT, { + featureIds: pids + }); + + // make sure analysis and timedensities are set properly based on dates + setTimeout(() => { + runAnalysis(newDatasetIds); + setTimeDensity( + getLowestCommonTimeDensity( + datasets.filter((dataset): + dataset is TimelineDatasetSuccess => dataset.status === DatasetStatus.SUCCESS) + ) + ); + }, (2000)); + break; + } + } + } catch (error) { + console.log('Error processing', error); + } + + content = [...content, answer]; + setConversation(content); + setLoading(false); + //close loading + }; + + const addNewResponse = () => { + const userContent = { + explanations: '', + query: query, + contentType: 'user' + }; + const length = conversation.length; + // merge user and system in one payload rather than multiple elements + const chatHistory = conversation.reduce((history, innerContent, index) => { + const identifier = innerContent.contentType; + let chatElement = {}; + if(identifier == 'user' && index != (length - 1)) { + chatElement = { inputs: {question: innerContent.query} }; + history.push(chatElement); + } + else { + const innerLength = history.length - 1; + if (innerContent.action) { + chatElement = { outputs: {answer: innerContent.summary} }; + } + else { + chatElement = { outputs: {answer: ''} }; + } + history[innerLength] = {...history[innerLength], ...chatElement}; + } + return history; + }, []); + + const content = [...conversation, userContent]; + setConversation(content); + setQuery(''); + setLoading(true); + + askGeoCoPilot({question: query, chat_history: chatHistory, content: content}, addSystemResponse); + }; + + const clearSession = () => { + setConversation([defaultSystemComment]); + }; + + return ( + + + Earth Copilot + + Restart Session + + + + + {conversation.map((convComponent, index) => { + if(convComponent.contentType == 'user') { + return ( + + ); + } + else if (convComponent.contentType == 'system') { + return ( + + ); + } + })} + +
+ + + setQuery(e.target.value)} + onKeyUp={(e) => e.code == 'Enter' ? addNewResponse() : ''} + /> + + + + ); +} + +export function GeoCoPilot(props: GeoCoPilotModalProps) { + return ; +} diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx index c9dc13c34..df41fc138 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -10,8 +10,10 @@ import { import { Layer } from './layer'; import { AnalysisMessageControl } from './analysis-message-control'; import { ShowTourControl } from './tour-control'; +import { GeoCoPilotControl } from '$components/exploration/components/geo-copilot/geo-copilot-control'; import Map, { Compare, MapControls } from '$components/common/map'; + import { Basemap } from '$components/common/map/style-generators/basemap'; import GeocoderControl from '$components/common/map/controls/geocoder'; import { @@ -32,10 +34,23 @@ interface ExplorationMapProps { setDatasets: (datasets: TimelineDataset[]) => void; selectedDay: Date | null; selectedCompareDay: Date | null; + openGeoCoPilot: () => void; + closeGeoCoPilot: () => void; + showGeoCoPilot: boolean; + setMap: (any) => void; } export function ExplorationMap(props: ExplorationMapProps) { - const { datasets, setDatasets, selectedDay, selectedCompareDay } = props; + const { + datasets, + setDatasets, + selectedDay, + selectedCompareDay, + showGeoCoPilot, + closeGeoCoPilot, + openGeoCoPilot, + setMap + } = props; const [projection, setProjection] = useState(projectionDefault); @@ -161,6 +176,7 @@ export function ExplorationMap(props: ExplorationMapProps) { + {comparing && ( // Compare map layers diff --git a/app/scripts/components/exploration/components/timeline/timeline.tsx b/app/scripts/components/exploration/components/timeline/timeline.tsx index 26c88266e..5b6d6cb41 100644 --- a/app/scripts/components/exploration/components/timeline/timeline.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline.tsx @@ -29,7 +29,7 @@ import styled from 'styled-components'; import { DatasetList } from '../datasets/dataset-list'; -import { applyTransform, getLabelFormat, getTemporalExtent, isEqualTransform, rescaleX } from './timeline-utils'; +import { applyTransform, getLabelFormat, getTemporalExtent, isEqualTransform, rescaleX, TemporalExtent } from './timeline-utils'; import { TimelineControls, getInitialScale, @@ -71,6 +71,7 @@ import { useAnalysisController } from '$components/exploration/hooks/use-analysi import useAois from '$components/common/map/controls/hooks/use-aois'; import Pluralize from '$utils/pluralize'; import { getLowestCommonTimeDensity } from '$components/exploration/data-utils-no-faux-module'; +import { TimeDensity as TimeDensityType} from '$components/exploration/types.d.ts'; const TimelineWrapper = styled.div` position: relative; @@ -172,6 +173,8 @@ interface TimelineProps { setSelectedCompareDay: (d: Date | null) => void; onDatasetAddClick: () => void; panelHeight: number; + startEndDates: TemporalExtent; + timeDensity: TimeDensityType | null; } const getIntervalFromDate = (selectedDay: Date, dataDomain: [Date, Date]) => { @@ -202,7 +205,9 @@ export default function Timeline(props: TimelineProps) { selectedCompareDay, setSelectedCompareDay, onDatasetAddClick, - panelHeight + panelHeight, + startEndDates, + timeDensity } = props; // Refs for non react based interactions. @@ -625,12 +630,21 @@ export default function Timeline(props: TimelineProps) { // Stub scale for when there is no layers const initialScale = useMemo(() => getInitialScale(width), [width]); - const minMaxTemporalExtent = useMemo( - () => getTemporalExtent( - // Filter the datasets to only include those with status 'SUCCESS'. - datasets.filter((dataset): dataset is TimelineDatasetSuccess => dataset.status === DatasetStatus.SUCCESS) - ), - [datasets] + const minMaxTemporalExtent = useMemo( + () => { + const temporalExtent = getTemporalExtent( + // Filter the datasets to only include those with status 'SUCCESS'. + datasets.filter((dataset): dataset is TimelineDatasetSuccess => dataset.status === DatasetStatus.SUCCESS) + ); + if (!temporalExtent[0] || !temporalExtent[1] || !startEndDates[0] || !startEndDates[1]) + return [undefined, undefined]; + + return startEndDates[0] ? + [((startEndDates[0] > temporalExtent[0]) ? startEndDates[0] : temporalExtent[0]), + ((startEndDates[1] > temporalExtent[1]) ? startEndDates[1] : temporalExtent[1])] : + temporalExtent; + }, + [datasets, startEndDates] ); const lowestCommonTimeDensity = useMemo( @@ -706,7 +720,7 @@ export default function Timeline(props: TimelineProps) { width={width} onZoom={onControlsZoom} outOfViewHeads={outOfViewHeads} - timeDensity={lowestCommonTimeDensity} + timeDensity={timeDensity || lowestCommonTimeDensity} timelineLabelsFormat={timelineLabelFormat} /> diff --git a/app/scripts/components/exploration/data-utils-no-faux-module.ts b/app/scripts/components/exploration/data-utils-no-faux-module.ts index c49804d1a..bfe0927d4 100644 --- a/app/scripts/components/exploration/data-utils-no-faux-module.ts +++ b/app/scripts/components/exploration/data-utils-no-faux-module.ts @@ -17,7 +17,6 @@ import { DATA_METRICS, DEFAULT_DATA_METRICS } from './components/datasets/analysis-metrics'; -import { DEFAULT_COLORMAP } from './components/datasets/colormap-options'; import { utcString2userTzDate } from '$utils/date'; import { DatasetLayer, @@ -64,8 +63,8 @@ function getInitialMetrics(data: DatasetLayer): DataMetric[] { return foundMetrics; } -function getInitialColorMap(dataset: DatasetLayer): string { - return dataset.sourceParams?.colormap_name ?? DEFAULT_COLORMAP; +function getInitialColorMap(dataset: DatasetLayer): string|undefined { + return dataset.sourceParams?.colormap_name; } export function reconcileDatasets( @@ -194,6 +193,10 @@ export function resolveRenderParams( datasetSourceParams: Record | undefined, queryDataRenders: Record | undefined ): Record | undefined { + // Return null if there are no user-configured sourcparams nor render parameter + // so it doesn't get subbed with default values + if (!datasetSourceParams && !queryDataRenders) return undefined; + // Start with sourceParams from the dataset. // Return the source param as it is if exists if (hasValidSourceParams(datasetSourceParams)) { diff --git a/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts index d0912213f..3d4fe1378 100644 --- a/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts +++ b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts @@ -50,12 +50,12 @@ function reconcileQueryDataWithDataset( let renderParams; - if (isRenderParamsApplicable(base.data.type)) { - renderParams = resolveRenderParams( - base.data.sourceParams, - queryData.data.renders - ); - } + // if (isRenderParamsApplicable(base.data.type)) { + // renderParams = resolveRenderParams( + // base.data.sourceParams, + // queryData.data.renders + // ); + // } base = { ...base, diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index d50b55512..a01f6df95 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -1,15 +1,21 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import styled from 'styled-components'; import { themeVal } from '@devseed-ui/theme-provider'; +import { Map as MapboxMap } from 'mapbox-gl'; +import { MapRef } from 'react-map-gl'; + import { useAtom, useSetAtom } from 'jotai'; import Timeline from './components/timeline/timeline'; import { ExplorationMap } from './components/map'; import { DatasetSelectorModal } from './components/dataset-selector-modal'; import { useAnalysisController } from './hooks/use-analysis-data-request'; -import { TimelineDataset } from './types.d.ts'; +import { TimelineDataset, TimeDensity } from './types.d.ts'; import { selectedCompareDateAtom, selectedDateAtom } from './atoms/dates'; +import { GeoCoPilot } from './components/geo-copilot/geo-copilot'; + +import { TemporalExtent } from './components/timeline/timeline-utils'; import { CLEAR_LOCATION, urlAtom } from '$utils/params-location-atom/url'; const Container = styled.div` @@ -31,6 +37,10 @@ const Container = styled.div` box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; } + .panel-geo-copilot { + height: 90vh; + } + .resize-handle { flex: 0; position: relative; @@ -72,6 +82,18 @@ function ExplorationAndAnalysis(props: ExplorationAndAnalysisProps) { !datasets.length ); + const [startEndDates, setStartEndDates] = useState( + [undefined, undefined] + ); + + const [showGeoCoPilot, setShowGeoCoPilot] = useState( + false + ); + + const [timeDensity, setTimeDensity] = useState(null); + + const [map, setMap] = useState(); + // @TECH-DEBT: panelHeight needs to be passed to work around Safari CSS const [panelHeight, setPanelHeight] = useState(0); @@ -89,33 +111,93 @@ function ExplorationAndAnalysis(props: ExplorationAndAnalysisProps) { }; }, [resetAnalysisController, setUrl]); + const mapPanelRef = useRef(null); + const geoCoPilotRef = useRef(null); + + const expandGeoCoPilotPanel = () => { + const mapPanel = mapPanelRef.current; + const geoCoPilotPanel = geoCoPilotRef.current; + if (mapPanel && geoCoPilotPanel) { + // panel.expand(50); + // resize panel from 0 to 50 + mapPanel.resize(75); + geoCoPilotPanel.resize(25); + setShowGeoCoPilot(true); + } + }; + + const closeGeoCoPilotPanel = () => { + const mapPanel = mapPanelRef.current; + const geoCoPilotPanel = geoCoPilotRef.current; + if (mapPanel && geoCoPilotPanel) { + mapPanel.resize(100); + geoCoPilotPanel.resize(0); + setShowGeoCoPilot(false); + } + }; + return ( - + { setPanelHeight(size); }} + ref={mapPanelRef} > - + + { + setPanelHeight(size); + }} + className='panel panel-map' + > + + + + + + + - - - + diff --git a/package.json b/package.json index a0d719ab3..da24bc948 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,10 @@ "@devseed-ui/typography": "4.1.0", "@emotion/react": "^11.11.3", "@floating-ui/react": "^0.25.1", + "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-regular-svg-icons": "^6.6.0", + "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/react-fontawesome": "^0.2.2", "@mapbox/mapbox-gl-draw": "^1.3.0", "@mapbox/mapbox-gl-draw-static-mode": "^1.0.1", "@mapbox/mapbox-gl-geocoder": "^5.0.1", @@ -208,6 +212,7 @@ "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", "react-indiana-drag-scroll": "^2.2.0", + "react-jsx-parser": "^2.1.0", "react-lazyload": "^3.2.0", "react-map-gl": "^7.1.5", "react-nl2br": "^1.0.2", @@ -215,6 +220,7 @@ "react-resizable-panels": "^0.0.45", "react-router": "^6.0.0", "react-router-dom": "^6.0.0", + "react-spinners": "^0.14.1", "react-transition-group": "^4.4.2", "react-virtual": "^2.10.4", "recharts": "2.1.12", diff --git a/yarn.lock b/yarn.lock index ae912cb93..49a06570d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1690,6 +1690,39 @@ resolved "http://verdaccio.ds.io:4873/@floating-ui%2futils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== +"@fortawesome/fontawesome-common-types@6.6.0": + version "6.6.0" + resolved "http://verdaccio.ds.io:4873/@fortawesome%2ffontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz#31ab07ca6a06358c5de4d295d4711b675006163f" + integrity sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw== + +"@fortawesome/fontawesome-svg-core@^6.6.0": + version "6.6.0" + resolved "http://verdaccio.ds.io:4873/@fortawesome%2ffontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz#2a24c32ef92136e98eae2ff334a27145188295ff" + integrity sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg== + dependencies: + "@fortawesome/fontawesome-common-types" "6.6.0" + +"@fortawesome/free-regular-svg-icons@^6.6.0": + version "6.6.0" + resolved "http://verdaccio.ds.io:4873/@fortawesome%2ffree-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz#fc49a947ac8dfd20403c9ea5f37f0919425bdf04" + integrity sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ== + dependencies: + "@fortawesome/fontawesome-common-types" "6.6.0" + +"@fortawesome/free-solid-svg-icons@^6.6.0": + version "6.6.0" + resolved "http://verdaccio.ds.io:4873/@fortawesome%2ffree-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz#061751ca43be4c4d814f0adbda8f006164ec9f3b" + integrity sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA== + dependencies: + "@fortawesome/fontawesome-common-types" "6.6.0" + +"@fortawesome/react-fontawesome@^0.2.2": + version "0.2.2" + resolved "http://verdaccio.ds.io:4873/@fortawesome%2freact-fontawesome/-/react-fontawesome-0.2.2.tgz#68b058f9132b46c8599875f6a636dad231af78d4" + integrity sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g== + dependencies: + prop-types "^15.8.1" + "@gulp-sourcemaps/identity-map@^2.0.1": version "2.0.1" resolved "http://verdaccio.ds.io:4873/@gulp-sourcemaps%2fidentity-map/-/identity-map-2.0.1.tgz#a6e8b1abec8f790ec6be2b8c500e6e68037c0019" @@ -4536,7 +4569,14 @@ dependencies: "@types/react" "^17" -"@types/react@*", "@types/react@18.0.32", "@types/react@^17": +"@types/react-dom@^18.3.0": + version "18.3.0" + resolved "http://verdaccio.ds.io:4873/@types%2freact-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@18.0.32", "@types/react@^17", "@types/react@^18.3.3": version "18.0.32" resolved "http://verdaccio.ds.io:4873/@types%2freact/-/react-18.0.32.tgz#5e88b2af6833251d54ec7fe86d393224499f41d5" integrity sha512-gYGXdtPQ9Cj0w2Fwqg5/ak6BcK3Z15YgjSqtyDizWUfx7mQ8drs0NBUzRRsAdoFVTO8kJ8L2TL8Skm7OFPnLUw== @@ -4769,6 +4809,11 @@ acorn@^8.0.0, acorn@^8.5.0, acorn@^8.7.1: resolved "http://verdaccio.ds.io:4873/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.12.1: + version "8.12.1" + resolved "http://verdaccio.ds.io:4873/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" + integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== + adler-32@~1.3.0: version "1.3.1" resolved "http://verdaccio.ds.io:4873/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2" @@ -5584,20 +5629,10 @@ camelize@^1.0.0: resolved "http://verdaccio.ds.io:4873/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b" integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= -caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001366: - version "1.0.30001441" - resolved "http://verdaccio.ds.io:4873/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz" - integrity sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg== - -caniuse-lite@^1.0.30001538: - version "1.0.30001640" - resolved "http://verdaccio.ds.io:4873/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz#32c467d4bf1f1a0faa63fc793c2ba81169e7652f" - integrity sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA== - -caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001629: - version "1.0.30001639" - resolved "http://verdaccio.ds.io:4873/caniuse-lite/-/caniuse-lite-1.0.30001639.tgz#972b3a6adeacdd8f46af5fc7f771e9639f6c1521" - integrity sha512-eFHflNTBIlFwP2AIKaYuBQN/apnUoKNhBdza8ZnW/h2di4LCZ4xFqYlxUxo+LQ76KFI1PGcC1QDxMbxTZpSCAg== +caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001366, caniuse-lite@^1.0.30001538, caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001629: + version "1.0.30001658" + resolved "http://verdaccio.ds.io:4873/caniuse-lite/-/caniuse-lite-1.0.30001658.tgz" + integrity sha512-N2YVqWbJELVdrnsW5p+apoQyYt51aBMSsBZki1XZEfeBCexcM/sf4xiAHcXQBkuOwJBXtWF7aW1sYX6tKebPHw== ccount@^1.0.0: version "1.1.0" @@ -12681,6 +12716,17 @@ react-json-tree@^0.18.0: "@types/lodash" "^4.14.191" react-base16-styling "^0.9.1" +react-jsx-parser@^2.1.0: + version "2.1.0" + resolved "http://verdaccio.ds.io:4873/react-jsx-parser/-/react-jsx-parser-2.1.0.tgz#605960947100625f70afc73f4ab915d92130f157" + integrity sha512-cGp8ceqA6j7OylsqeK2kuCGRitHpWzximsxsQyqkQO+1fwyG82Mb1l2nx9ImrfdCO6GT2kgt7C6cX9vJ46B7ow== + dependencies: + acorn "^8.12.1" + acorn-jsx "^5.3.2" + optionalDependencies: + "@types/react" "^18.3.3" + "@types/react-dom" "^18.3.0" + react-lazyload@^3.2.0: version "3.2.0" resolved "http://verdaccio.ds.io:4873/react-lazyload/-/react-lazyload-3.2.0.tgz#497bd06a6dbd7015e3376e1137a67dc47d2dd021" @@ -12781,6 +12827,11 @@ react-smooth@^2.0.1: fast-equals "^2.0.0" react-transition-group "2.9.0" +react-spinners@^0.14.1: + version "0.14.1" + resolved "http://verdaccio.ds.io:4873/react-spinners/-/react-spinners-0.14.1.tgz#de7d7d6b3e6d4f29d9620c65495b502c7dd90812" + integrity sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag== + react-style-singleton@^2.2.1: version "2.2.1" resolved "http://verdaccio.ds.io:4873/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"