Entity system

A map in the JS API can be represented as a collection of various objects that are combined into a common tree structure. Different types of these objects interact with each other, forming a system of entities.

In this section, we will consider this system, its types of entities, their features and purpose, and also learn how to create our own custom objects based on this system.

The entity system is an imperative part of the JS API.

Basic entities

Basic entities are abstract classes. Tree components are created by inheriting them.

There are the following types of entities:

  • Entity – basic entity;
  • Complex Entity – is an entity that has its own subtree of entities, but does not have a public interface for interacting with it;
  • Group Entity – similar to Complex Entity, but it has a public interface to interact with a sub-tree of entities;
  • Root Entity – is a root entity that cannot be part of a subtree of another entity. Similarly, Group Entity has a public interface for managing its own subtree.

If we consider the components from the mappable module, then here is a list with a correspondence to each type of entity:

  • Root Entity – MMap;
  • Group Entity – MMapControls, MMapControl, MMapMarker;
  • Complex Entity – MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer;
  • Entity – MMapTileDataSource, MMapFeatureDataSource, MMapLayer, MMapListener, MMapFeature.

More information about each type of entity will be written in the relevant sections.

Entity

The basic entity. Cannot contain its own subtree, but can be added to another entity.

In the mappable module, there is an abstract class MMapEntity in order to create an Entity type entity component using inheritance from that class:

type MMapSomeEntityProps = {
  name: string;
};

class MMapSomeEntity extends mappable.MMapEntity<MMapSomeEntityProps> {
  public isAttached: boolean;
  constructor(props: MMapSomeEntityProps) {
    super(props);
    this.isAttached = false;
    // Additional actions that can be performed in the class constructor.
  }
  protected _onAttach(): void {
    this.isAttached = true;
    // Additional actions that can be performed when attaching an object.
  }
  // ...
}

The MMapEntity class contains various protected methods for overriding to create event handlers for various events of the entity lifecycle.

The _onAttach and _onDetach methods are handlers for inserting and deleting entities from the parent subtree, respectively:

class MMapSomeEntity extends mappable.MMapEntity<MMapSomeEntityProps> {
  // When attaching an entity to a parent subtree.
  protected _onAttach(): void {
    console.log('attach entity');
  }

  // When detaching an entity to a parent subtree.
  protected _onDetach(): void {
    console.log('detach entity');
  }
}

Each entity can have its own props stored as an object with values. A class constructor takes an object of props as the first argument, and the props can then be updated using the update method, supplying only the updated props (the other parameters will not be changed). To get the actual values of the entity props, there is a property _props.

The _onUpdate method is a handler for updating entity properties via the update method. It takes two arguments: the new prop values and the old prop values:

type MMapSomeEntityProps = {
  visible: boolean;
};
class MMapSomeEntity extends mappable.MMapEntity<MMapSomeEntityProps> {
  private _visible = false;
  // Triggered by updating entity parameters.
  protected _onUpdate(propsDiff: Partial<MMapSomeEntityProps>, oldProps: MMapSomeEntityProps): void {
    // Since there is a difference in parameters, it is worth checking for undefined before saving the new value.
    if (propsDiff.visible !== undefined) {
      this._visible = propsDiff.visible;
    }
    // this._props will contain already updated values.
  }
}

The Entity instance MMapEntity also has links to other related entities in the tree. To obtain an instance of the parent entity, you have a readonly parent property which returns an entity of Complex Entity type. A similar property root returns a root entity of the type Root Entity.

Complex Entity

An entity that can contain child components, but the methods for attaching and detaching to the subtree are internal.

In the mappable module, there is an abstract class, MMapComplexEntity. This class inherits from the base class MMapEntity and therefore inherits all the properties and methods of the parent class. The class also has additional methods to work with its own subtree. To create an entity of the Complex Entity type, you can inherit from the MMapComplexEntity class.

type MMapSomeLayerProps = {
  visible: boolean;
  source: string;
};

class MMapSomeLayer extends mappable.MMapComplexEntity<MMapSomeLayerProps> {
  private _dataSource?: MMapTileDataSource;
  private _layer?: MMapLayer;
  constructor(props: MMapSomeLayerProps) {
    super(props);
    const {source, visible} = this._props;
    // Creating instances of child entities.
    this._dataSource = new mappable.MMapTileDataSource({id: source});
    this._layer = new mappable.MMapLayer({source, type: 'ground'});
    // Adding child entities to a component's subtree.
    this.addChild(this._dataSource);
    if (visible) {
      this.addChild(this._layer);
    }
  }
  protected _onUpdate(propsDiff: Partial<MMapSomeLayerProps>): void {
    if (propsDiff.visible !== undefined) {
      // Detaching or attaching entities depends on the value of visible.
      propsDiff.visible ? this.addChild(this._layer) : this.removeChild(this._layer);
    }
  }
}

In the example above, methods were used to interact with its own subtree. The addChild method – adds another entity to the subtree. All child entities are stored in some array, therefore, as the second optional argument, the method takes an ordinal number in place of which the entity will be added to this array. The removeChild method – removes the child entity from the subtree.

To get a list of child entities, there is a readonly array children.

Also, the constructor of the MMapComplexEntity class has a second optional argument options — an object with the following properties:

  • children – array of child entities that will be added to the subtree immediately after initialization of the class instance.
  • container – if true, it creates a child proxy container that contains a subtree containing child components. A proxy container is created by default.

For more information about proxy containers, see the relevant section.

Group Entity

The entity is similar to Complex Entity, but the methods addChild, removeChild and the children property are public.

In the mappable module, there is an abstract class MMapGroupEntity to create your own entities of the Group Entity type using inheritance:

type MMapSomeGroupEntityProps = {
  name?: string;
};

class MMapSomeGroupEntity extends mappable.MMapGroupEntity<MMapSomeGroupEntityProps> {
  // ...
}

const groupEntity = new MMapSomeGroupEntity();
const someEntity = new MMapSomeEntity(); // MMapSomeEntity inherits from MMapEntity.

// Adding another entity from a subtree via a public method.
groupEntity.addChild(someEntity);
// Removing another entity from a subtree via a public method.
groupEntity.removeChild(someEntity);

Root Entity

It is the root entity in the tree, so it cannot be added to another subtree as a child element.

In addition to other types of entities, you do not need to write your own implementation of the root entity. To do this, there is a class MMap in the mappable module, which should be used to create a tree and is the root for MMapEntity, MMapComplexEntity, MMapGroupEntity.

Examples of using MMap can be found on the examples page.

Warning

To define your own entities, it is recommended to inherit only from MMapEntity, MMapComplexEntity, MMapGroupEntity. When inheriting from other classes, backward compatibility is not guaranteed.

Default Props

An entity may have optional props, but you can set default values for them. There are two ways to do this:

  • If the default value is static, use the static field of the defaultProps class.
  • If the default value is calculated dynamically, apply the protected method _getDefaultProps.

For example, an entity has an optional parameter name, but we want to set the default value to 'some-entity' in case it is not explicitly specified.

An example for the static default value of the name parameter:

type MMapSomeEntityProps = {
  name?: string;
};

const defaultProps = Object.freeze({name: 'some-entity'});
type DefaultProps = typeof defaultProps;

class MMapSomeEntity extends mappable.MMapEntity<MMapSomeEntityProps, DefaultProps> {
  static defaultProps = defaultProps;
}

A similar example, but for a dynamically calculated default value:

type MMapSomeEntityProps = {
  id?: string;
  name?: string;
};

type DefaultProps = {name: string};

class MMapSomeEntity extends mappable.MMapGroupEntity<MMapSomeEntityProps, DefaultProps> {
  private static _uid = 0; // entity counter

  protected _getDefaultProps(props: MMapSomeEntityProps): DefaultProps {
    const id = props.name !== undefined ? `id-${props.name}` : `id-${MMapSomeEntity._uid++}_auto`;
    return {id};
  }
}

Note

In order for optional parameters with a default value not to be optional in TypeScript, you must specify the default value type in the generic class.

To do this, we created the DefaultProps type and passed it to the second generic.

Binding an entity to the DOM

Entities are not binding to the DOM tree in any way, since they are all located in a separate virtual tree. To bind an entity to a DOM element, while maintaining the order and nesting of entities, you need to use the useDomContext hook:

class MMapDOMEntity extends mappable.MMapGroupEntity<{text: string}> {
  private _element?: HTMLHeadingElement;
  private _container?: HTMLDivElement;
  private _detachDom?: () => void;

  protected _onAttach(): void {
    // Creating a DOM element that will be bound to an entity.
    this._element = document.createElement('div');
    this._element.textContent = this._props.text;
    this._element.classList.add('element');

    // Creating a container to which the DOM elements of child entities will be added.
    this._container = document.createElement('div');
    this._container.classList.add('container');

    // Inserting the container inside the element.
    this._element.appendChild(this._container);

    // Creating an entity binding to the DOM.
    this._detachDom = mappable.useDomContext(this, this._element, this._container);
  }
  protected _onDetach(): void {
    // Detach the DOM from the entity and remove the references to the elements.
    this._detachDom?.();
    this._detachDom = undefined;
    this._element = undefined;
    this._container = undefined;
  }
}
// Initializing the root element.
map = new mappable.MMap(document.getElementById('app'), {location: LOCATION});
// Initializing the entities of the MMapDOMEntity class.
const DOMEntity = new MMapDOMEntity({text: 'DOM Entity'});
const childDOMEntity = new MMapDOMEntity({text: 'Child DOM Entity'});

// DOMEntity._element will be inserted into the DOM element of the map.
map.addChild(DOMEntity);
// childDOMEntity._element will be added to the DOMEntity._container.
DOMEntity.addChild(childDOMEntity);
.element {
  position: absolute;
  top: 0;
  width: 100%;
}
.container {
  position: absolute;
  top: 24px;
  width: 100%;
}

This hook is available only when inheriting from MMapComplexEntity and MMapGroupEntity, since it is assumed that an entity may have child elements. The hook accepts 3 arguments.

  1. entity — a reference to the entity to which the DOM element binding is being created.
  2. element — DOM-the element to which the entity is bound.
  3. container — The DOM element to which the DOM elements of the child entities are to be inserted (if an entity does not involve the addition of child elements then null is passed).

The useDomContext hook returns a function that removes the binding to the DOM element.

Proxy container

Proxy containers can be created in derived classes of MMapComplexEntity and MMapGroupEntity, via the constructor with a second argument, options.container.

To begin with, it is worth understanding some of the internal properties and mechanisms of these classes without using a proxy container.

Inside the class there is a protected property _childContainer. By default, it is a reference to the current object (this). It was also said above that the children property returns an array of child elements. But in fact, the child elements of the entity are stored in a private property of the class, and children is just an accessor property to it.

In addition to the already known addChild, there is the protected method _addDirectChild (similar to removeChild and _removeDirectChild), which adds a child entity directly to the internal subtree. This is the difference from addChild, which adds an entity inside _childContainer.

So, all children entities are accessible through the children property and can be removed from a subtree. However, this can cause problems if we have a child element but don't want an external user to have access to it:

import type {LngLat, MMapFeature} from '@mappable-world/mappable-types';

type MMapSomeProps = {
  coordinates: LngLat;
};

class MMapWithoutContainerEntity extends mappable.MMapGroupEntity<MMapSomeProps> {
  private _feature: MMapFeature;

  constructor(props: MMapSomeProps) {
    super(props); // MMapWithoutContainerEntity does not have a proxy container.

    this._feature = new mappable.MMapFeature({
      geometry: {
        type: 'Point',
        coordinates: this._props.coordinates
      }
    });
    this.addChild(this._feature); // Adding _feature to the inner subtree.
    // this._addDirectChild(this._feature); — similar action.
  }

  private _removeFeature() {
    this.removeChild(this._feature); // Removing _feature from the inner subtree.
    // this._removeDirectChild(this._feature); — similar action.
  }
}

const withoutContainerEntity = new MMapWithoutContainerEntity({coordinates: [0, 0]});
const [feature] = withoutContainerEntity.children; // Getting access to withoutContainerEntity._feature.
withoutContainerEntity.removeChild(feature); // We can remove the closed entity from the subtree.

Now, let's look at how the mechanism for adding child entities will change when using a proxy container.

import type {LngLat, MMapFeature} from '@mappable-world/mappable-types';

type MMapSomeProps = {
  coordinates: LngLat;
};

class MMapWithContainerEntity extends mappable.MMapGroupEntity<MMapSomeProps> {
  private _feature: MMapFeature;

  constructor(props: MMapSomeProps) {
    super(props, {container: true}); // MMapWithContainerEntity has a proxy container

    this._feature = new mappable.MMapFeature({
      geometry: {
        type: 'Point',
        coordinates: this._props.coordinates
      }
    });
    // this.addChild(this._feature); — adds _feature to the open subtree inside this._childContainer.
    this._addDirectChild(this._feature); // Adding _feature to the inner closed subtree.
  }

  private _removeFeature() {
    // this.removeChild(this._feature); — removes _feature from an open subtree inside this._childContainer.
    this._removeDirectChild(this._feature); // Removing _feature from the inner closed subtree.
  }
}

const withContainerEntity = new MMapWithContainerEntity({coordinates: [0, 0]});

const publicFeature = mappable.MMapFeature({
  geometry: {type: 'Point', coordinates: [1, 1]}
});

withContainerEntity.addChild(publicFeature);

// We can only get publicFeature, because withContainerEntity._feature is in a closed subtree.
const [feature] = withContainerEntity.children;
withoutContainerEntity.removeChild(feature); // We can remove publicFeature from the subtree.

First, the _childContainer now contains a proxy container, a child entity, similar to an instance of a class. Secondly, the children function now returns an inner subtree within _childContainer. It does not return the class instance. This change allows entities to have two types of subtree: an open subtree and a closed subtree.

Internally, a subtree of a class instance is private because there is currently no access to this subtree, and the entities added using _addDirectChild are stored here (which can only be removed using _removeDirectChild).

The internal tree _childContainer is public, because it can be accessed via the public property children, and it stores entities added via addChild, which can only be removed via removeChild.

Let's compare the differences between entities using a proxy container and without it in the form of a table:

Without a proxy container Using a proxy container
_childContainer Instance of the class (this) Proxy container
children Returns the internal subtree of the entity Returns the internal subtree of the proxy container
addChild Adds child elements to the internal subtree of the entity Adds child elements to the inner subtree of the proxy container
_addDirectChild Adds child elements to the internal subtree of the entity Adds child elements to the internal subtree of the entity
removeChild Removes child elements from the internal subtree of an entity Removes child elements from the inner subtree of the proxy container
_removeDirectChild Removes child elements from the internal subtree of an entity Removes child elements from the internal subtree of an entity