import bbox from '@turf/bbox' import { BBox } from '@turf/helpers' import { Feature, FeatureCollection } from 'geojson' import { castArray, difference, every, uniq } from 'lodash' import log from 'loglevel' import { action, CancellablePromise, flow, IValueDidChange, makeObservable, observable, observe, toJS, } from 'mobx' import { ElementData } from '../interfaces/element.interface' import { DatabaseFeature, GeojsonEditData, GisBackupStatus, HighlightMode, IdentifierHash, LayerGroups, NearbyQuery, OfflinePack, SearchResult, } from '../interfaces/gis.interface' import { GisFeature } from '../models/_default/gisFeature' import { ListingConfig } from '../models/_default/listingConfig' import { UserElement } from '../models/_default/userElement' import { Element } from '../models/element' import { GisElement } from '../models/gisElement' import type { ApiResponse } from '../services/api' import { Toast } from '../services/toast' import { MainStore } from './mainStore' export class GisStore { main: MainStore features: Map = new Map() availableLayers: string[] = [] selectionGroup: IdentifierHash[] = [] layerGroups?: LayerGroups = undefined activeFeatures: IdentifierHash[] = [] selectedFeatures: IdentifierHash[] = [] highlightedFeatures: IdentifierHash[] = [] mapPresets: Map = new Map() activeMapPresets: string[] = [] mapPacks: OfflinePack[] = [] backupStatus?: GisBackupStatus = undefined constructor(main: MainStore) { makeObservable(this, { features: observable, availableLayers: observable, selectionGroup: observable, layerGroups: observable, activeFeatures: observable, selectedFeatures: observable, highlightedFeatures: observable, mapPresets: observable, activeMapPresets: observable, mapPacks: observable, backupStatus: observable, handleModeChange: action, setSelected: action, clearFeatures: action, setHighlighted: action, setActive: action, loadListingPresets: action, loadNear: flow, loadLayers: action, loadLayerGroups: action, addFeature: action, getElementsForFeatures: action, getFeaturesForElements: action, getFeatureForElement: action, }) this.main = main observe(this, 'activeFeatures', this.handleModeChange('active')) observe(this, 'selectedFeatures', this.handleModeChange('selected')) observe(this, 'highlightedFeatures', this.handleModeChange('highlight')) } handleModeChange = (mode: HighlightMode) => ({ newValue, oldValue }: IValueDidChange) => { const newV = toJS(newValue) const oldV = toJS(oldValue) const removed = difference(oldV, newV) removed.forEach((id) => { this.features.get(id)?.setMode(this.getMode(id)) }) newV.forEach((id) => this.features.get(id)?.setMode(mode)) } getMode(id: IdentifierHash): HighlightMode { if (this.activeFeatures.includes(id)) { return 'active' } else if (this.selectedFeatures.includes(id)) { return 'selected' } else if (this.highlightedFeatures.includes(id)) { return 'highlight' } return null } setSelected(ids: IdentifierHash[]) { this.selectedFeatures = ids } pushSelected(ids: IdentifierHash[]) { const newIds = ids.filter((id) => !this.selectedFeatures.includes(id)) this.selectedFeatures.push(...newIds) } async appendSelectionToElement(element: Element) { const elements = await this.getElementsForFeatures( toJS(this.selectedFeatures) .map((id) => this.features.get(id)?.data) .filter(Boolean) as DatabaseFeature[], ) const geoAttribute = (element.constructor as typeof Element) .GEO_RELATION_ATTRIBUTES?.[0] if (!geoAttribute) { return } const current = element.getAttributeElementValue(geoAttribute) || [] const newValue: Element[] = [] await element.update({ [geoAttribute]: newValue.concat(current, elements), }) this.selectedFeatures = [] } clearFeatures() { this.features = new Map() } setHighlighted(ids: IdentifierHash[]) { this.highlightedFeatures = ids } setActive(ids: IdentifierHash[]) { this.activeFeatures = ids } async loadListingPresets() { const listingModel = this.main.models.elementModels?.listingConfig if (!listingModel) { return } const mapPreset = listingModel .LISTING_PRESETS(this.main) .find((preset) => preset.name === 'mapListingPresets') if (!mapPreset) { return } try { const mapListingConfigs = await this.main.listingStore.getElements( mapPreset.type, mapPreset.criteria, mapPreset.projection, undefined, mapPreset.page, mapPreset.limit, ) if (!mapListingConfigs) { return } this.mapPresets = mapListingConfigs.elements.reduce((acc, preset) => { acc.set(preset.hash, preset) return acc }, new Map()) this.setActiveListingPresets() return mapListingConfigs } catch (e) { console.error(e) return } } setActiveListingPresets() { const userElement = this.main.userStore.userElement const defaultMapAttribute = (userElement?.constructor as typeof UserElement) .ATTRIBUTES.DEFAULT_MAP_LISTING // User has defined some default attributes, use those const userMapListings: string[] = [] if ( userElement && defaultMapAttribute && userElement.attributeExists(defaultMapAttribute) ) { const personDefault = userElement .getAttributeElementValue(defaultMapAttribute) ?.filter((pd) => pd instanceof ListingConfig) if (!personDefault) { return } const personPresets = personDefault .filter((p) => this.mapPresets.has(p.hash)) .map((p) => p.hash) userMapListings.push(...personPresets) } const truthyValues = ['true', 'Yes'] const organizationPresets = [...this.mapPresets] .filter(([, mpe]) => truthyValues.includes( mpe.getAttributeRawValue( (mpe.constructor as typeof ListingConfig).ATTRIBUTES.DEFAULT_ACTIVE, ) as string, ), ) .map(([hash]) => hash) // use all selected as default this.activeMapPresets = uniq([...userMapListings, ...organizationPresets]) } loadNear: (query: NearbyQuery, layers: string[]) => CancellablePromise = flow(function* (this: GisStore, query: NearbyQuery, layers: string[]) { const newFeatures = new Map() const isConnected = this.main.connectivityService?.isConnected const hasOfflineSynced = this.backupStatus?.status === 'complete' // Set of unique hashes we want to keep const hashesToKeep = new Set([ ...this.selectedFeatures, ...this.highlightedFeatures, ...this.activeFeatures, ]) ;[...this.features].forEach(([identifierHash, f]) => { if (hashesToKeep.has(identifierHash)) { newFeatures.set(identifierHash, f) } }) let databaseFeatures: DatabaseFeature[] if (isConnected && !hasOfflineSynced) { log.debug('loading network') databaseFeatures = yield this.loadNearNetwork(query, layers) } else { log.debug('loading local') databaseFeatures = [] } if (!databaseFeatures) { return } const gisFeatures = this.instantiateFeatures(databaseFeatures) gisFeatures?.forEach((gf) => newFeatures.set(gf.identifierHash, gf)) this.features = newFeatures }) async loadNearNetwork(query: NearbyQuery, layers: string[]) { const organizationId = this.main.userStore.organization.id const nearRequest = await this.main.geojson.near( query, organizationId, layers, ) if (!nearRequest.ok) { Toast.show('Cannot load nearby map objects', 'danger') return [] } return nearRequest.data?.features } updateFeature(feature: DatabaseFeature) { const identifier = feature?.properties?.identifierHash if (!identifier || !this.features.has(identifier)) { return } const featureModel = this.features.get(identifier) if (!featureModel) { return } featureModel.data = feature } instantiateFeatures(features: DatabaseFeature[], elementContext?: Element) { const gisFeatureModel = this.main.models.gisFeature if (!gisFeatureModel) { return } return features .map((feature) => { const hash = feature?.properties?.identifierHash if (!hash) { return null } const gisFeature = this.features.get(hash) if (!gisFeature) { const featureModel = new gisFeatureModel(this.main, feature) featureModel.setElementContext(elementContext) return featureModel } else { return gisFeature } }) .filter(Boolean) as GisFeature[] } async loadLayers() { const organizationId = this.main.userStore.organization.id const layerRequest = await this.main.geojson.loadLayers(organizationId) if (!layerRequest.ok || !layerRequest.data) { Toast.show('Cannot load layers', 'danger') return } this.availableLayers = layerRequest.data } async loadLayerGroups() { const organizationId = this.main.userStore.organization.id const layerRequest = await this.main.geojson.loadLayerGroups(organizationId) if (!layerRequest.ok || !layerRequest.data) { Toast.show('Cannot load layer groups', 'danger') return } this.layerGroups = layerRequest.data.groups this.availableLayers = uniq(Object.values(layerRequest.data.groups).flat()) return layerRequest.data.groups } async addFeature( geojson: Feature, layerName: string, ): Promise { const organizationId = this.main.userStore.organization.id const addRequest = await this.main.geojson.addFeature( geojson, layerName, organizationId, ) if (!addRequest.ok || !addRequest.data) { Toast.show('Cannot create map object', 'danger') return } return } async getElementsForFeatures( feature: DatabaseFeature | DatabaseFeature[], ): Promise { const features = castArray(toJS(feature)) const identifiers = features.map((f) => f.properties.identifierHash) const newElementPair = await this.main.geojson.createElementsForFeatures( this.main.userStore.organization.id, identifiers, ) if (!newElementPair.ok || newElementPair.data?.length === 0) { Toast.show('Element creation failed', 'danger') return [] } return newElementPair.data ?.map((pair) => { return this.main.elementStore.instantiateElement(pair.elementHash, { _type: pair.type, organization: this.main.userStore.organization.id, } as ElementData) }) .filter(Boolean) as Element[] } async updateGeojson(geoIdentifier: string, data: GeojsonEditData) { const editRequest = await this.main.geojson.editFeature(geoIdentifier, data) if (!editRequest.ok) { Toast.show('Cannot update geojson layer', 'danger') return } return editRequest.data } findFeatureFromGeojson(geojson: Feature): DatabaseFeature | undefined { const identifierHash = geojson.properties?.identifierHash if (!identifierHash || !this.features.has(identifierHash)) { return } return this.features.get(identifierHash)?.data } async findElementFromFeature( feature: DatabaseFeature, ): Promise { if (!feature?.properties?.identifierHash) { console.warn('No identifierHash on feature') return null } if (feature.element) { const element = await this.main.elementStore.get( feature.element.elementHash, feature.element.type, ) await this.main.elementStore.getElementTypeData(feature.element.type) if (element instanceof GisElement) { const gisFeature = this.instantiateFeatures([feature])?.[0] if (!gisFeature) { return null } this.features.set(gisFeature.identifierHash, gisFeature) element.feature = gisFeature } return element } return this.main.listingStore.getFirstElement( null, { [GisElement.ATTRIBUTES.GEO_IDENTIFIER]: feature.properties.identifierHash, }, Object.values(GisElement.ATTRIBUTES), ) } async loadSearchQuery(query: string): Promise { const isConnected = this.main.connectivityService?.isConnected const hasOfflineSynced = this.backupStatus?.status === 'complete' if (isConnected) { return this.loadSearchQueryNetwork(query) } else if (hasOfflineSynced) { // return this.loadSearchQueryLocal(query) return [] } else { Toast.show( 'You are offline & no addresses are in local database', 'danger', ) return [] } } async loadSearchQueryNetwork(query: string): Promise { const { map: mapConfig } = this.main.userStore.organizationConfig if (!mapConfig) { return [] } const geocodingRequest = await this.main.geojson.geocodingQuery(query, { location: (mapConfig.searchBias || mapConfig.initialMapCenter || []).join( ',', ), radius: 10000, }) if (!geocodingRequest) { return [] } if (!geocodingRequest.ok || !geocodingRequest.data?.predictions) { Toast.show('Search failed', 'danger') return [] } return geocodingRequest.data.predictions.map((result) => ({ text: result.description, id: result.place_id, })) } // async loadSearchQueryLocal(query: string): Promise { // const results = this.main.offlineStore.realm // .objects('Address') // .filtered(`address_clean CONTAINS[c] '${query}' LIMIT(10)`) // return results.map((result) => { // const data = JSON.parse(result.data) // const center = data?.geometry?.coordinates // return { // text: result.address, // center, // } // }) // } async getPlaceLocation(placeId: string) { const response = await this.main.geojson.getPlaceLocation(placeId) if (!response.ok || response.data?.results.length === 0) { Toast.show('Search failed', 'danger') return } return response.data?.results[0].geometry.location } refreshSessionToken() { this.main.geojson.generateSessionToken() } async getFeaturesForElements(elements: GisElement[] | string[]) { let hashes: string[] if (every(elements, (el) => el instanceof GisElement)) { hashes = (elements as GisElement[]).map((el) => el.hash) } else { hashes = elements as string[] } const featuresRequest = await this.main.geojson.getFeaturesForElements(hashes) if (!featuresRequest.ok || !featuresRequest.data) { Toast.show('Cannot get features', 'danger') return null } if (featuresRequest.data?.length !== elements.length) { console.warn("Some elements don't have features") } const gisFeatures = this.instantiateFeatures(featuresRequest.data) gisFeatures?.forEach((gf) => this.features.set(gf.identifierHash, gf)) return featuresRequest.data } async getFeatureForElement( element: GisElement, ): Promise { const identifierHash = element.getAttributeTextValue( (element.constructor as typeof GisElement).ATTRIBUTES.GEO_IDENTIFIER, ) if (!identifierHash) { return null } const featureRequest: ApiResponse = await this.main.geojson.getFeature(identifierHash) if (!featureRequest.ok) { Toast.show('Cannot load element geojson', 'danger') return null } const feature: DatabaseFeature = featureRequest.data as DatabaseFeature return feature } getFeatureBBox(feature: DatabaseFeature | Feature | FeatureCollection): BBox { return bbox(feature) } createFeatureElementAndCall = async ( gisFeature: GisFeature, afterCreate: (element: Element) => void, ) => { gisFeature.isLoading = true if (gisFeature.element) { gisFeature.isLoading = false afterCreate(gisFeature.element) return } const [newfeatureElement] = await this.getElementsForFeatures( gisFeature.data, ) if (!newfeatureElement) { gisFeature.isLoading = false return } gisFeature.data.element = { elementHash: newfeatureElement.hash, identifierHash: gisFeature.data.properties.identifierHash, type: newfeatureElement.type as string, } gisFeature.isLoading = true if (newfeatureElement) { afterCreate(newfeatureElement) } } } // const getSimpleCoordinates = (feature: DatabaseFeature): [number, number] => { // let centerPoint: Feature = centroid(feature) // return centerPoint.geometry.coordinates as [number, number] // }