terriajs
Version:
Geospatial data visualization platform.
523 lines (461 loc) • 18.1 kB
text/typescript
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 */
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);
}
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>;
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.
*/
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])
);
}
}
});
}
refreshKnownContainerUniqueIds(uniqueId: string | undefined): void {
if (!uniqueId) return;
this.memberModels.forEach((model: BaseModel) => {
if (model.knownContainerUniqueIds.indexOf(uniqueId) < 0) {
model.knownContainerUniqueIds.push(uniqueId);
}
});
}
addItemPropertiesToMembers(): void {
this.memberModels.forEach((model: BaseModel) => {
applyItemProperties(this, model);
});
}
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);
}
}
});
}
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);
}
}
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
*/
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;
}
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)
);
});
}