UNPKG

terriajs

Version:

Geospatial data visualization platform.

523 lines (461 loc) 18.1 kB
import { uniq } from "lodash-es"; import { action, computed, makeObservable, runInAction } from "mobx"; import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError"; import clone from "terriajs-cesium/Source/Core/clone"; import AbstractConstructor from "../Core/AbstractConstructor"; import AsyncLoader from "../Core/AsyncLoader"; import { JsonObject, isJsonNumber, isJsonString } from "../Core/Json"; import Result from "../Core/Result"; import filterOutUndefined from "../Core/filterOutUndefined"; import flatten from "../Core/flatten"; import isDefined from "../Core/isDefined"; import CatalogMemberFactory from "../Models/Catalog/CatalogMemberFactory"; import Group from "../Models/Catalog/Group"; import CommonStrata from "../Models/Definition/CommonStrata"; import Model, { BaseModel } from "../Models/Definition/Model"; import hasTraits, { HasTrait } from "../Models/Definition/hasTraits"; import ModelReference from "../Traits/ModelReference"; import GroupTraits from "../Traits/TraitsClasses/GroupTraits"; import { ItemPropertiesTraits } from "../Traits/TraitsClasses/ItemPropertiesTraits"; import CatalogMemberMixin, { getName } from "./CatalogMemberMixin"; import ReferenceMixin from "./ReferenceMixin"; import naturalSort from "javascript-natural-sort"; naturalSort.insensitive = true; const MERGED_GROUP_ID_PREPEND = "__merged__"; type BaseType = Model<GroupTraits>; function GroupMixin<T extends AbstractConstructor<BaseType>>(Base: T) { abstract class _GroupMixin extends Base implements Group { private _memberLoader = new AsyncLoader(this.forceLoadMembers.bind(this)); constructor(...args: any[]) { super(...args); makeObservable(this); } get isGroup() { return true; } /** * Gets a value indicating whether the set of members is currently loading. */ get isLoadingMembers(): boolean { return this._memberLoader.isLoading; } get loadMembersResult() { return this._memberLoader.result; } /** Get merged excludeMembers from all parent groups. This will go through all knownContainerUniqueIds and merge all excludeMembers arrays */ @computed get mergedExcludeMembers(): string[] { const blacklistSet = new Set(this.excludeMembers ?? []); this.knownContainerUniqueIds.forEach((containerId) => { const container = this.terria.getModelById(BaseModel, containerId); if (container && GroupMixin.isMixedInto(container)) { container.mergedExcludeMembers.forEach((s) => blacklistSet.add(s)); } }); return Array.from(blacklistSet); } @computed get memberModels(): ReadonlyArray<BaseModel> { const members = this.members; if (members === undefined) { return []; } const includeMemberRegex = this.includeMembersRegex ? new RegExp(this.includeMembersRegex, "i") : undefined; const models = filterOutUndefined( members.map((id) => { if (!ModelReference.isRemoved(id)) { const model = this.terria.getModelById(BaseModel, id); // Get model name, apply includeMemberRegex and excludeMembers const modelName = CatalogMemberMixin.isMixedInto(model) ? model.name?.toLowerCase().trim() : undefined; const modelId = model?.uniqueId?.toLowerCase().trim(); if ( model && // Does includeMemberRegex match model ID or model name (!includeMemberRegex || (modelId && includeMemberRegex.test(modelId)) || (modelName && includeMemberRegex.test(modelName))) && // Does excludeMembers not include model ID !this.mergedExcludeMembers.find( (name) => modelId === name.toLowerCase().trim() || (modelName && modelName === name.toLowerCase().trim()) ) ) return model; } }) ); // Sort members if necessary // Check if trait "this.sortMembersBy" exists and is a string or number // If not, then the model will be placed at the end of the array if (isDefined(this.sortMembersBy)) { return models.sort((a, b) => { const aValue = getSortProperty(a, this.sortMembersBy!); const bValue = getSortProperty(b, this.sortMembersBy!); return naturalSort( isJsonString(aValue) || isJsonNumber(aValue) ? aValue : Infinity, isJsonString(bValue) || isJsonNumber(bValue) ? bValue : Infinity ); }); } return models; } /** * Load the group members if necessary. Returns an existing promise * if the members are already loaded or if loading is already in progress, * so it is safe and performant to call this function as often as * necessary. When the promise returned by this function resolves, the * list of members in `GroupMixin#members` and `GroupMixin#memberModels` * should be complete, but the individual members will not necessarily be * loaded themselves. * * This returns a Result object, it will contain errors if they occur - they will not be thrown. * To throw errors, use `(await loadMetadata()).throwIfError()` * * {@see AsyncLoader} */ async loadMembers(): Promise<Result<void>> { try { // Call loadMetadata if CatalogMemberMixin if (CatalogMemberMixin.isMixedInto(this)) (await this.loadMetadata()).throwIfError(); // Call Group AsyncLoader if no errors occurred while loading metadata (await this._memberLoader.load()).throwIfError(); // Order here is important, as mergeGroupMembersByName will create models and the following functions will be applied on memberModels this.mergeGroupMembersByName(); this.refreshKnownContainerUniqueIds(this.uniqueId); this.addShareKeysToMembers(); this.addItemPropertiesToMembers(); } catch (e) { return Result.error(e, `Failed to load group \`${getName(this)}\``); } return Result.none(); } /** * Forces load of the group members. This method does _not_ need to consider * whether the group members are already loaded. When the promise returned * by this function resolves, the list of members in `GroupMixin#members` * and `GroupMixin#memberModels` should be complete, but the individual * members will not necessarily be loaded themselves. * * If creating new models (eg WebMapServiceCatalogGroup), use `CommonStrata.definition` for trait values. * * It is guaranteed that `loadMetadata` has finished before this is called. * * You **can not** make changes to observables until **after** an asynchronous call {@see AsyncLoader}. * * Errors can be thrown here. * * {@see AsyncLoader} */ protected abstract forceLoadMembers(): Promise<void>; @action toggleOpen(stratumId: string) { this.setTrait(stratumId, "isOpen", !this.isOpen); } /** "Merges" group members with the same name if `mergeGroupsByName` Trait is set to `true` * It does this by: * - Creating a new CatalogGroup with all members of each merged group * - Appending merged group ids to `excludeMembers` * This is only applied to the first level of group members (it is not recursive) * `mergeGroupsByName` is not applied to nested groups automatically. */ @action mergeGroupMembersByName() { if (!this.mergeGroupsByName) return; // Create map of group names to group models const membersByName = new Map<string, GroupMixin.Instance[]>(); this.memberModels.forEach((member) => { if ( GroupMixin.isMixedInto(member) && CatalogMemberMixin.isMixedInto(member) && member.name ) { // Push member to map // eslint-disable-next-line @typescript-eslint/no-unused-expressions membersByName.get(member.name)?.push(member) ?? membersByName.set(member.name, [member]); } }); membersByName.forEach((groups, name) => { if (groups.length > 1) { const groupIdsToMerge = groups .map((g) => g.uniqueId) .filter(isJsonString); const mergedGroupId = `${this.uniqueId}/${MERGED_GROUP_ID_PREPEND}${name}`; let mergedGroup = this.terria.getModelById(BaseModel, mergedGroupId); // Create mergedGroup if it doesn't exist - and then add it to group.members if (!mergedGroup) { mergedGroup = CatalogMemberFactory.create( "group", mergedGroupId, this.terria ); if (mergedGroup) { // We add groupIdsToMerge as shareKeys here for backward compatibility this.terria.addModel(mergedGroup, groupIdsToMerge); this.add(CommonStrata.override, mergedGroup); } } // Set merged group traits - name and members // Also set excludeMembers to exclude all groups that are merged. if ( GroupMixin.isMixedInto(mergedGroup) && CatalogMemberMixin.isMixedInto(mergedGroup) ) { mergedGroup.setTrait(CommonStrata.definition, "name", name); mergedGroup.setTrait( CommonStrata.definition, "members", flatten(groups.map((g) => [...g.members])) ); this.setTrait( CommonStrata.override, "excludeMembers", uniq([...(this.excludeMembers ?? []), ...groupIdsToMerge]) ); } } }); } @action refreshKnownContainerUniqueIds(uniqueId: string | undefined): void { if (!uniqueId) return; this.memberModels.forEach((model: BaseModel) => { if (model.knownContainerUniqueIds.indexOf(uniqueId) < 0) { model.knownContainerUniqueIds.push(uniqueId); } }); } @action addItemPropertiesToMembers(): void { this.memberModels.forEach((model: BaseModel) => { applyItemProperties(this, model); }); } @action addShareKeysToMembers(members = this.memberModels): void { const groupId = this.uniqueId; if (!groupId) return; // Get shareKeys for this Group const shareKeys = this.terria.modelIdShareKeysMap.get(groupId); if (!shareKeys || shareKeys.length === 0) return; /** * Go through each shareKey and create new shareKeys for members * - Look at current member.uniqueId * - Replace instances of group.uniqueID in member.uniqueId with shareKey * For example: * - group.uniqueId = 'some-group-id' * - member.uniqueId = 'some-group-id/some-member-id' * - group.shareKeys = 'old-group-id' * - So we want to create member.shareKeys = ["old-group-id/some-member-id"] * * We also repeat this process for each shareKey for each member */ members.forEach((model: BaseModel) => { // Only add shareKey if model.uniqueId is an autoID (i.e. contains groupId) if (isDefined(model.uniqueId) && model.uniqueId.includes(groupId)) { shareKeys.forEach((groupShareKey) => { // Get shareKeys for current model const modelShareKeys = this.terria.modelIdShareKeysMap.get( model.uniqueId! ); modelShareKeys?.forEach((modelShareKey) => { this.terria.addShareKey( model.uniqueId!, modelShareKey.replace(groupId, groupShareKey) ); }); this.terria.addShareKey( model.uniqueId!, model.uniqueId!.replace(groupId, groupShareKey) ); }); // If member is a Group -> apply shareKeys to the next level of members if (GroupMixin.isMixedInto(model)) { this.addShareKeysToMembers(model.memberModels); } } }); } @action add(stratumId: string, member: BaseModel) { if (member.uniqueId === undefined) { throw new DeveloperError( "A model without a `uniqueId` cannot be added to a group." ); } const members = this.getTrait(stratumId, "members"); if (isDefined(members)) { members.push(member.uniqueId); } else { this.setTrait(stratumId, "members", [member.uniqueId]); } if ( this.uniqueId !== undefined && member.knownContainerUniqueIds.indexOf(this.uniqueId) < 0 ) { member.knownContainerUniqueIds.push(this.uniqueId); } } @action addMembersFromJson(stratumId: string, members: any[]): Result { const newMemberIds = this.traits["members"].fromJson( this, stratumId, members ); newMemberIds .ignoreError() ?.map((memberId: string) => this.terria.getModelById(BaseModel, memberId) ) .forEach((member: BaseModel) => { this.add(stratumId, member); }); if (newMemberIds.error) return Result.error( newMemberIds.error, `Failed to add members from JSON for model \`${this.uniqueId}\`` ); return Result.none(); } /** * Used to re-order catalog members * * @param stratumId name of the stratum to update * @param member the member to be moved * @param newIndex the new index to shift the member to * * @returns true if the member was moved to the new index successfully */ @action moveMemberToIndex(stratumId: string, member: BaseModel, newIndex: number) { if (member.uniqueId === undefined) { throw new DeveloperError( "Cannot reorder a model without a `uniqueId`." ); } const members = this.members; const moveFrom = members.indexOf(member.uniqueId); if (members[newIndex] === undefined) { throw new DeveloperError(`Invalid 'newIndex' target: ${newIndex}`); } if (moveFrom === -1) { throw new DeveloperError( `A model couldn't be found in the group ${this.uniqueId} for member uniqueId ${member.uniqueId}` ); } const cloneArr = clone(members); // shift a current member to the new index cloneArr.splice(newIndex, 0, cloneArr.splice(moveFrom, 1)[0]); this.setTrait(stratumId, "members", cloneArr); return true; } @action remove(stratumId: string, member: BaseModel) { if (member.uniqueId === undefined) { return; } const members = this.getTrait(stratumId, "members"); if (isDefined(members)) { const index = members.indexOf(member.uniqueId); if (index !== -1) { members.splice(index, 1); } } } dispose() { super.dispose(); this._memberLoader.dispose(); } } return _GroupMixin; } namespace GroupMixin { export interface Instance extends InstanceType< ReturnType<typeof GroupMixin> > {} export function isMixedInto(model: any): model is Instance { return ( model && "isGroup" in model && model.isGroup && "forceLoadMembers" in model && typeof model.forceLoadMembers === "function" ); } } export default GroupMixin; function getSortProperty(model: BaseModel, prop: string) { return (CatalogMemberMixin.isMixedInto(model) && hasTraits(model, model.TraitsClass, prop as any)) || (GroupMixin.isMixedInto(model) && hasTraits(model, model.TraitsClass, prop as any)) || (ReferenceMixin.isMixedInto(model) && hasTraits(model, model.TraitsClass, prop as any)) ? model[prop!] : undefined; } function setItemPropertyTraits( model: BaseModel, itemProperties: JsonObject | undefined ) { if (!itemProperties) return; Object.keys(itemProperties).map((k: any) => model.setTrait(CommonStrata.override, k, itemProperties[k]) ); } /** Applies itemProperties object to a model - this will set traits in override stratum. * Also copy ItemPropertiesTraits to target if it supports them */ export function applyItemProperties( model: HasTrait<ItemPropertiesTraits, "itemProperties"> & HasTrait<ItemPropertiesTraits, "itemPropertiesByType"> & HasTrait<ItemPropertiesTraits, "itemPropertiesByIds"> & BaseModel, target: BaseModel ) { runInAction(() => { if (!target.uniqueId) return; // Apply itemProperties to non GroupMixin targets if (!GroupMixin.isMixedInto(target)) setItemPropertyTraits(target, model.itemProperties); // Apply itemPropertiesByType setItemPropertyTraits( target, model.itemPropertiesByType.find( (itemProps) => itemProps.type && itemProps.type === target.type )?.itemProperties ); // Apply itemPropertiesByIds model.itemPropertiesByIds.forEach((itemPropsById) => { if (itemPropsById.ids.includes(target.uniqueId!)) { setItemPropertyTraits(target, itemPropsById.itemProperties); } }); // Copy over ItemPropertiesTraits from model, if target has them // For example GroupMixin and ReferenceMixin if (hasTraits(target, ItemPropertiesTraits, "itemProperties")) target.setTrait( CommonStrata.underride, "itemProperties", model.traits.itemProperties.toJson(model.itemProperties) ); if (hasTraits(target, ItemPropertiesTraits, "itemPropertiesByType")) target.setTrait( CommonStrata.underride, "itemPropertiesByType", model.traits.itemPropertiesByType.toJson(model.itemPropertiesByType) ); if (hasTraits(target, ItemPropertiesTraits, "itemPropertiesByIds")) target.setTrait( CommonStrata.underride, "itemPropertiesByIds", model.traits.itemPropertiesByIds.toJson(model.itemPropertiesByIds) ); }); }