diff --git a/src/components/InventoryList.tsx b/src/components/InventoryList.tsx index 0e590e5..26e31d3 100644 --- a/src/components/InventoryList.tsx +++ b/src/components/InventoryList.tsx @@ -6,10 +6,12 @@ import { IonLabel, IonList, IonListHeader, + IonSpinner, IonThumbnail, } from "@ionic/react"; import { bookmarkOutline, + banOutline, navigate, swapVerticalOutline, arrowDownOutline, @@ -24,8 +26,13 @@ import { useSettings } from "../context/settings"; import { InventoryFeature } from "../context/data.model"; import "./InventoryList.css"; +import { useSelection } from "../context/selection"; +import { useState } from "react"; const InventoryList: React.FC = () => { + // add a state for processing selections + const [selectionProcessing, setSelectionProcessing] = useState(false) + // load the filtered inventory list const { filteredInventory, @@ -35,6 +42,9 @@ const InventoryList: React.FC = () => { setSortDirection } = useData(); + // get selection functions + const { addToActiveSelection, removeFromActiveSelection, activeSelection } = useSelection() + // get a history context const history = useHistory(); @@ -76,10 +86,23 @@ const InventoryList: React.FC = () => { }; const addToBookmarksHandler = ( - event: React.MouseEvent + event: React.MouseEvent, + treeId: number ) => { + // set selection processing + setSelectionProcessing(true) + + // stop event propagation event.stopPropagation(); - console.log("add to bookmarks"); + + // check if this treeId is already in the selection + if (activeSelection?.selection.treeIds.includes(treeId)) { + removeFromActiveSelection(treeId).finally(() => setSelectionProcessing(false)) + } else { + addToActiveSelection(treeId).finally(() => setSelectionProcessing(false)) + } + + //console.log("add to bookmarks"); }; return ( @@ -145,8 +168,13 @@ const InventoryList: React.FC = () => { {distString(f)}

- addToBookmarksHandler(e)}> - + addToBookmarksHandler(e, Number(f.properties.treeid))} + > + { selectionProcessing ? : ( + + )} ); diff --git a/src/components/TreeDetails.tsx b/src/components/TreeDetails.tsx index 6df1a4f..519a184 100644 --- a/src/components/TreeDetails.tsx +++ b/src/components/TreeDetails.tsx @@ -1,4 +1,6 @@ import { + IonButton, + IonButtons, IonCard, IonCardContent, IonCardHeader, @@ -6,6 +8,7 @@ import { IonCardTitle, IonCol, IonGrid, + IonIcon, IonItem, IonLabel, IonNote, @@ -13,12 +16,14 @@ import { IonSegment, IonSegmentButton, } from "@ionic/react"; +import { bookmarkOutline } from "ionicons/icons"; import { Data, Layout } from "plotly.js"; import { useEffect, useState } from "react"; import Plot from "react-plotly.js"; import { useData } from "../context/data"; import { InventoryFeature } from "../context/data.model"; import { useOffline } from "../context/offline"; +import { useSelection } from "../context/selection"; import * as plot from "../util/plot"; import "./TreeDetails.css"; @@ -61,6 +66,9 @@ const TreeDetails: React.FC = ({ treeID }) => { // load all inventory data const { filteredInventory, allInventory } = useData(); + // get selection functions + const { addToActiveSelection } = useSelection() + // compnent state to store this feature const [feature, setFeature] = useState(); const [currentImg, setCurrentImg] = useState(); diff --git a/src/components/map-components/InventorySource.tsx b/src/components/map-components/InventorySource.tsx index 235a2eb..bb22419 100644 --- a/src/components/map-components/InventorySource.tsx +++ b/src/components/map-components/InventorySource.tsx @@ -6,18 +6,8 @@ import { Source, Layer, useMap } from "react-map-gl"; import { useData } from "../../context/data"; import { InventoryData, InventoryFeature } from "../../context/data.model"; import { useLayers } from "../../context/layers"; -import { - IonCard, - IonCardContent, - IonCardHeader, - IonCardTitle, - IonItem, - IonLabel, - IonPopover, -} from "@ionic/react"; import { useHistory } from "react-router"; import { useOffline } from "../../context/offline"; -import bbox from "@turf/bbox"; const InventoryLayer: React.FC = () => { const { diff --git a/src/components/map-components/MainMapMaplibre.tsx b/src/components/map-components/MainMapMaplibre.tsx index 3b82c2a..7154127 100644 --- a/src/components/map-components/MainMapMaplibre.tsx +++ b/src/components/map-components/MainMapMaplibre.tsx @@ -8,6 +8,7 @@ import InventorySource from "./InventorySource"; import BaseLayerSource from "./BaseLayerSource"; import UserLocationSource from "./UserLocationSource"; import LayerInteraction from "./LayerInteraction"; +import SelectionSource from "./SelectionSource"; const MainMap: React.FC = () => { // onload callback handler @@ -75,6 +76,7 @@ const MainMap: React.FC = () => { > + diff --git a/src/components/map-components/SelectionSource.tsx b/src/components/map-components/SelectionSource.tsx new file mode 100644 index 0000000..2e28cf1 --- /dev/null +++ b/src/components/map-components/SelectionSource.tsx @@ -0,0 +1,45 @@ +import cloneDeep from "lodash.clonedeep" +import { useEffect, useState } from "react" +import { Layer, Source } from "react-map-gl" +import { InventoryData } from "../../context/data.model" +import { useSelection } from "../../context/selection" + +const SelectionSource: React.FC = () => { + // define a component state for the selection GeoJSON + const [src, setSrc] = useState() + + // subscribe to the current active selection + const { activeSelection } = useSelection() + + // update component state when activeSelection changes + useEffect(() => { + if (activeSelection) { + setSrc(cloneDeep(activeSelection.geoJSON)) + } else { + setSrc(undefined) + } + }, [activeSelection]) + + // if there is no source, return null + if (!src) { + return ( + null + ) + } else { + return ( + + + + ) + } +} + +export default SelectionSource \ No newline at end of file diff --git a/src/context/offline.tsx b/src/context/offline.tsx index 67d57ef..96fa273 100644 --- a/src/context/offline.tsx +++ b/src/context/offline.tsx @@ -41,8 +41,8 @@ interface OfflineState { remoteChecksums: Checksums | null; getImageData: (name: string) => Promise getBaselayer: (name: string) => Promise - createSelection: (treeIds: number[], title?: string) => Promise - updateSelection: (selection: InventorySelection) => Promise + createSelection: (treeIds: number[], title?: string) => Promise + updateSelection: (selection: InventorySelection) => Promise dropSelection: (selectionId: string) => Promise } @@ -289,7 +289,7 @@ export const OfflineProvider: React.FC = ({ children }) } - const createSelection = async (treeIds: number[], title?: string): Promise => { + const createSelection = async (treeIds: number[], title?: string): Promise => { // create a random 16 character string as id const id = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) @@ -304,7 +304,7 @@ export const OfflineProvider: React.FC = ({ children }) return updateSelection(selection) } - const updateSelection = async (selection: InventorySelection): Promise => { + const updateSelection = async (selection: InventorySelection): Promise => { // create the selections folder if it does not exist if (!fileInfos?.map(i => i.name).includes('selections')) { await Filesystem.mkdir({path: '/selections', directory: Directory.Data}) @@ -323,16 +323,16 @@ export const OfflineProvider: React.FC = ({ children }) // create the new array of selections let newSelections: InventorySelection[] = [] if (selections) { - newSelections = cloneDeep([...selections, selection]) + newSelections = cloneDeep([...selections.filter(s => s.id !== selection.id), {...selection}]) } else { - newSelections = [selection] + newSelections = [{...selection}] } // update the selections state variable setSelections(newSelections) // return the local path - return path + return selection }) } diff --git a/src/context/selection.tsx b/src/context/selection.tsx index 1d72ffa..02fe23d 100644 --- a/src/context/selection.tsx +++ b/src/context/selection.tsx @@ -1,7 +1,6 @@ import bbox from "@turf/bbox"; import cloneDeep from "lodash.clonedeep"; -import { createContext, useContext, useEffect, useState } from "react"; -import { useData } from "./data"; +import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { InventoryData } from "./data.model"; import { InventorySelection } from "./inventory-selection.model"; import { useOffline } from "./offline"; @@ -15,12 +14,16 @@ interface SelectionState { selections: InventorySelection[]; activeSelection: ActiveSelection | null; setActiveSelection: (selectionId: string | null) => void + addToActiveSelection: (treeId: number | string) => Promise + removeFromActiveSelection: (treeID: number | string) => Promise } const initialState: SelectionState = { selections: [], activeSelection: null, - setActiveSelection: (selectionId: string | null) => {} + setActiveSelection: (selectionId: string | null) => {}, + addToActiveSelection: (treeId: number | string) => Promise.reject('Not implemented.'), + removeFromActiveSelection: (treeID: number | string) => Promise.reject('Not implemented.'), } // add the context @@ -30,11 +33,13 @@ const SelectionContext = createContext(initialState); export const SelectionProvider: React.FC = ({ children }) => { // create the state const [selections, setSelections] = useState(initialState.selections) + const [activeSelectionId, setActiveSelectionId] = useState(null) const [activeSelection, setActiveSelectionState] = useState(initialState.activeSelection) // get a reference to all inventory data - const { filteredInventory } = useData() - const {selections: offlineSelections} = useOffline() + const { inventory } = useOffline() + + const {selections: offlineSelections, createSelection, updateSelection} = useOffline() // subscribe to changes in offline selections useEffect(() => { @@ -45,40 +50,103 @@ export const SelectionProvider: React.FC = ({ children } }, [offlineSelections]) - // define the context functions + // setActiveSelection handler const setActiveSelection = (selectionId: string | null) => { - if (selectionId === null || !filteredInventory) { - setActiveSelectionState(null) + if (!selectionId) { + setActiveSelectionId(null) + } else { + setActiveSelectionId(selectionId) + } + } + + const addToActiveSelection = async (treeId: number | string): Promise => { + // skip if the treeId is already selection + if (activeSelection && activeSelection.selection.treeIds.includes(Number(treeId))) { + return Promise.reject(`The tree ID=${treeId} is already part of the current selection`) + } + // get the selection representation from the current data or create a new one + let selection: InventorySelection; + if (!activeSelection) { + // create a new selection + selection = await createSelection([Number(treeId)], `New Selection ${selections.length}`) } else { - const selection = selections.find(s => s.id === selectionId) + // update + activeSelection.selection.treeIds.push(Number(treeId)) + selection = await updateSelection(activeSelection.selection) + } + + // build the geojson + const newActiveSelection = getSelectionWithGeoJSON(selection) + setActiveSelectionState(newActiveSelection) + + // set the selection active if it was not + if (activeSelectionId !== selection.id) { + setActiveSelectionId(selection.id) + } + } + + const removeFromActiveSelection = async (treeId: number | string): Promise => { + // skip if the treeId is not in selection + if (activeSelection && !activeSelection.selection.treeIds.includes(Number(treeId))) { + return Promise.reject(`Tree ID=${treeId} is not in the active selection.`) + } + + // remove the treeId + const newTreeIds = activeSelection?.selection.treeIds.filter(id => id !== treeId) || [] + + // update the selection + const selection = await updateSelection({...activeSelection!.selection, treeIds: newTreeIds}) + + // build the geoJSON + const newActiveSelection = getSelectionWithGeoJSON(selection) + setActiveSelectionState(newActiveSelection) + + // set the selection as the new active selection + if (activeSelectionId !== selection.id) { + setActiveSelectionId(selection.id) + } + } + + const getSelectionWithGeoJSON = useCallback((selection: InventorySelection): ActiveSelection => { + // filter the full inventory for any selected tree + const features = inventory?.features.filter(f => selection.treeIds.includes(Number(f.properties.treeid))) || [] + + // create the geojson + const data = { + type: 'FeatureCollection', + features: cloneDeep(features), + } as InventoryData + + // add the bounding box + data.bbox = bbox(data) + + return {selection: cloneDeep(selection), geoJSON: cloneDeep(data)} + }, [inventory]) + + // use effect to update the current active selection, when the activeSelectionId or the filteredInventory changes + useEffect(() => { + if (!inventory) return + if (activeSelectionId) { + const selection = selections.find(s => s.id === activeSelectionId) if (selection) { - // extract the features from the inventory data that match the selection - const features = filteredInventory.features.filter(f => selection.treeIds.includes(Number(f.properties.treeid))) - - // create the geojson - const data = { - type: 'FeatureCollection', - features: cloneDeep(features), - } as InventoryData - - // add the bounding box - data.bbox = bbox(data) - - setActiveSelectionState({ - selection: cloneDeep(selection), - geoJSON: cloneDeep(data) - }) + const newActiveSelection = getSelectionWithGeoJSON(selection) + setActiveSelectionState(newActiveSelection) } else { setActiveSelectionState(null) } + } else { + setActiveSelectionState(null) } - } + }, [activeSelectionId, inventory, selections, getSelectionWithGeoJSON]) + // create the context value const value = { selections, activeSelection, - setActiveSelection + setActiveSelection, + addToActiveSelection, + removeFromActiveSelection } // return the provider