A Methodology to Build a Versatile iModel.js Application
By Zachary Zhu
Hello, I am a third-year undergraduate student at Lehigh University. Since February 2020, I have been working on an open-source project with Bentley Systems Inc. The goal has been to develop an interactive campus map with Bentley’s open-source library, iModel.js, for Lehigh University. Throughout the development phases, I have come up with some important ideas to help future developers build a robust iModel.js Frontend Application.
Introduction
By the end of this reading, you will learn a powerful methodology to integrate external data with iModel elements and build an interactive system for your iModel.js frontend application.
I’ll cover these topics in the following order:
- What inspired me to create this methodology
- Potential problems with the current system
- Use a tree data structure to illustrate the power of this methodology
- Build a data-driven and interactive system from the ground up
- Further discussion
- Notes
To explain the concepts better, I’ll use my project, entirely written in TypeScript, as an example to illustrate them.
Stretching the idea of Digital Twin
One aspect of Digital Twin is successfully established with the idea of iModel, which not only represents the internal properties such as width, height, and weight but also the edit history of real-world constructions. In my words, iModel has achieved the internal representation of construction such as a building, a bridge, etc. Nonetheless, there is another aspect of Digital Twin that is difficult to achieve with iModel technology: the representation of the external data of real-world constructions, the data that an iModel owner does not have control over.
Problems of working solely with iModel(s)
To achieve external representation of objects, some sort of mapping must exist to connect the two worlds: the iModel world and the external data world. It may be tempting to just store the external data along with the iModel but it has several tradeoffs. The first problem is that iModel is in itself stored in an SQLite database which is essentially a file and is not appropriate for write-intensive programs, unlike Client/Server database engines. “SQLite supports an unlimited number of simultaneous readers, but it will only allow one writer at any instant in time.” (https://www.sqlite.org/whentouse.html). The second problem is the extra complexity involved in writing efficient and correct EC-SQL statements, which are optimized to be compiled into different SQL statements to be executed on the SQLite database. From my experience, it was a time-consuming process. Another problem is that it prevents developers from using Object-Relational Mappers, such as Hibernate (Java), SQLAlchemy (Python), to speed up development.
Problems of representing external data
Imagine that we have an iModel that contains a few buildings and the external data for them, their floors, and their rooms. We can map each iModel building footprint element to the building data, map maybe each iModel floor plan element to the floor data, and each iModel door elements to the room data. However, a real building includes more than a building footprint, a floor may include more than a floor plan, and a room includes more than a door. One to one mapping may not work out perfectly because that external data represent objects that are more general such as buildings and rooms while iModel represents objects in the most granular level. Although BIS Model element in an iModel is capable of representing a more general object as a collection of smaller elements, it may still be difficult to find models that are in the same granularity as a given set of external data. Analogously, think of an iModel as a Lego model, it tells you the meaning of each Lego brick, while external data usually tell you the meaning of each model component, which is composed of multiple Lego bricks.
An important idea here is that to allow an iModel to flexibly adopt external data, an extra layer of abstraction is needed to fill in the gap between the objects described by external data and the elements in iModel.
Technically, one can keep modifying an iModel to match the external data for it (e.g. creating room Models to store room data) but this may require the programmer to have expertise in editing an iModel.
Key Terms
Mapper is an object created by a TypeScript class. It stores three tables that have the following mappings:
- EC instance ID to Matching Key
- Matching Key to EC instance ID
- Matching Key to Data Object
Data Object is an object created by a TypeScript class with external data. It contains a Matching Key value, which is the key value of a data item in the external data, and the value of that data item. In the following example, “PackardLab” would be the Matching Key value, and “BuildingNumber” and “BuildingName” would be stored in the “data” field of this Data Object.
A Data Object extends this base class:
An EC instance ID uniquely identifies an EC instance in an iModel. It is also called an Element ID.
A Matching Key must uniquely identify a Data Object and optionally identifies an EC instance if it’s stored as a FederationGuid or another attribute. A Matching Key is also a Data Object ID given its nature.
Controlled Data refers to the data that are managed by the owner of an app, the data that we have control over. For example, in our project, building names and building numbers (stored in our campus iModel) would be categorized as controlled data, since we can change the building names however we like.
Free Data refers to the data are not managed by us. For example, our app could retrieve energy consumption data for each Lehigh buildings from the servers owned by Lehigh University and those energy consumption values are not determined by us. So they would be categorized as Free Data, given that they come from an external source and their schema and value are not managed by us.
A UI Event involves interacting with components populated by external data.
A Canvas Event is triggered by a change to the application state caused by user interaction with the iModel.
The Fundamentals
Suppose that we get an array of dictionaries in JSON format from a HTTP response:
Instead of representing them with JSON, we will convert each member of the array as a Data Object (TypeScript syntactic-sugar object encapsulated in our program) and allow it to be connected with other Data Objects. As a result, we would instantiate one-hundred Data Objects to represent the data items in the array above.
Remember people used to work with flat-file database before the idea of relational database was conceived which later dramatically increased the efficiency of enterprises’ systems and allowed them to manage system complexities better. It is the same idea here but we’re doing even more than that — — we not only allow each JSON data item to be seen as an atomic object, build relationships (NOT inheritance) between them, but also most importantly, allow them to be capable of messaging (in Alan Kay’s term), likewise have member functions. We’re bringing these JSON data items to life so that they can manage themselves and interact with others, and with them as our building blocks, we can start building a versatile yet controllable system.
Relationships:
By looking at these objects as nodes or vertices, we can build all sorts of data structures tailored to our app. One interesting data structure to describe a detailed building such as the retail store sample would be a tree data structure whose tree nodes and direct children are Data Objects and an array of Data Objects, respectively. Data Objects on a lower level down the tree are physically contained by their parent Data Objects in the real world such that a floor is physically contained in a building. The reason why tree structure is chosen is that it resembles the structure of constructions. For example, a building always has a hierarchy associated with its subparts such that a building includes floors, a floor includes rooms, and a room includes maybe windows. Higher-level BuildingDataObjects would contain a pointer (named “children”) to an array containing the next lower level FloorDataObjects, and it continues until tree leaf nodes are reached.
If your iModel is more like a map rather than a detailed building with your Data Objects. The choice of data structure depends on the nature of your iModel. A great benefit: once you have a proper data structure set up, you can run all kinds of algorithms associated with it. For example, a Breadth-first Search Algorithm can run on a tree data structure to update buildings since users see the most outer surface of an iModel first. In this case, it might make less sense to run it on a graph data structure since users have an overview of all the elements in an iModel in which fewer components are contained or hidden by bigger components.
Note that it may not make sense to have an inheritance relationship between our sample Data Objects because they don’t resemble each other in any way, a building is nowhere similar to a room. Instead, their relationship should be established by a data structure where they are interconnected by pointers.
Messaging:
Once a data structure is established to glue Data Objects together, the relationships between Data Objects can be leveraged to manipulate their interactions. In the following, I’ll be using the same tree data structure whose tree nodes are BuildingDataObject, FloorDataObject, and RoomDataObject to illustrate the power of messaging.
The three main benefits that messaging introduces:
- Propagation: A signal can be propagated from any tree node to its descendants and trigger them to call an inherited function until the bottom of the tree is reached. For example, if all three of our Data Objects need to call different API endpoints to update their data and the data of an entire building need to be updated, a way to implement this is by creating a generic update function that is inherited by all Data Objects classes, and override it with a specific implementation in these Data Object classes. Then, one can create another propagating function (also inherited by all Data Object) that not only calls the update function of the Data Object but its direct children, which then call the propagating function on their children… Eventually, the entire subtree including the starting node would update itself entirely. By calling an update function on a BuildingDataObject, all of the FloorDataObjects and RoomDataObjects would be consequently updated from their data source. Note that an update function is only an example. My implementation of the example above. Various other interesting functions could be implemented such as a delete function that truncates the whole subtree.
- Aggregation: The member functions of tree nodes can be used to gather information from its descendants as well. For example, if a BuildingDataObject calls “getDailyPower()”, this function would automatically and recursively get all the DailyPower value from its children, FloorDataObject, average them, and store as its DailyPower value; each FloorDataObject would then do the same thing to its children, RoomDataObject(s), which in this case are the leaf nodes so we will stop here. Each floor’s DailyPower is calculated from their rooms’ DailyPower and each building’s DailyPower is calculated from the resulted DailyPower of their floors’ DailyPower. My implementation of this example.
- Implicit Optimization: With the power of propagation in a tree, implementing a Data Object member function that implicitly uses optimization algorithms can abstract away some of the complexity in our program. For example, if the goal is to maximize the number of meetings scheduled in a building or on a floor, the scheduler can greedily pick the room, with the least number of seats, that can hold all the meeting attendees. This greedy algorithm maximizes the number of meetings scheduled with two conditions: 1. meetings are scheduled in a First-in-First-out order and 2. the starting and ending time of a meeting are unknown. When a BuildingDataObject calls this function to schedule a meeting, it would implicitly use the greedy algorithm to optimally schedule a meeting in a building, while FloorDataObject would instead optimally schedule a meeting on a floor. My implementation of this example.
Controlled Data
Working with Controlled Data is simple, you can either store them in an iModel (if they’re not prone to change) or have an external database where you can include EC instance ID as an additional column(attribute) to the actual data table for mapping purposes. Whenever some canvas events took place on the frontend (e.g. when I select on a building footprint), we can pass down the ID(s) of the selected EC instance(s) as parameters for the HTTP request and finally, send it over to our backend to retrieve the data linked to the EC instance ID(s), or call “IModelConnection.query(…)”.
Free Data
Free data has more complexity to deal with as we’re querying a database that’s not ours and whose schema does not contain EC instance ID as a column. If we don’t know the EC instance ID, we would need some other identifier to link an EC instance to the data related to it. Now, this is where Mapper comes into play:
Since we know the data, we could choose one of its unique identifiers as a Matching Key. Then we would either programmatically or manually build a hash table (in memory), depends on whether the iModel data share any common value with the external data, luckily in our project, we found that BuildingNumber is not only a unique identifier but it is stored by both iModel (in a separate schema) and our external data source (energy consumption data). Therefore, we programmatically pair each EC instance ID to each Matching Key and store the mapping in a table. If no such common attribute exists in your iModel and external data source, you may need to manually write a mapping.
The created table maps each Matching Key to its corresponding EC instance ID. Let’s call it KeyToEcTable. We can then get the reversed mapping by swapping the key and value. And let’s call this EcToKeyTable. If we have the value of a Matching Key, we could get the Data Object since matching key acts as a unique identifier for it. To avoid O(n) for each lookup for data object with a matching key, we build another table called KeyToDataTable to get O(1). It is important to construct the KeyToDataTable upon the initialization of your app to avoid showing missing data to users.
Mapper essentially handles all the tedious conversions for our app.
Handling UI / Canvas Event
A UI Event involves interacting with components populated by external data. For example, our app displays a list of Card elements for each Lehigh building on a side drawer and each Card element contains some information about the building and a couple of buttons that indicate zoom-in and highlight. When a user clicks on the zoom-in button, we would then trigger a UI event, which is handled by a UI event handler function that takes in the key of the clicked data object and calls the iModel zoom-in function. To zoom in to a building in iModel canvas, we need to use KeyToEcTable to convert the key value (Matching Key) into EC instance ID, which serves as a required parameter for functions that create changes to any elements in an iModel.
A Canvas Event is triggered by a change to the application state caused by user interaction with the iModel. In most cases, an iModel.js callback handler would pass an KeySet of the EC instances affected by that change. For example, when I click on one of the building footprints in our campus map iModel, the event object passed by the iModel.js callback handler, onSelectionChange, would contain the EC instance ID of the particular building footprint I selected. Once the EC instance ID is captured, we then use EcToKeyTable and KeyToDataTable to get the Data Object of this selected building.
Further Discussion
What if we have multiple data sources? Previously, we assumed a one-to-one relation between the EC instance ID and Matching Key. Now, with multiple data sources for each EC instance, we would need to establish a one-to-many relation between them. If the data sources share the same identifier value, the new data from a different source can be merged into existing Data Objects based on their matching key values. If they do not share any, we would need to create another Mapper class that extends GenericMapper and adopts the same technique to build the three mapping tables with the additional Data Objects.
If you recall “Problems of representing external data”, you know that external data could describe a model that contains multiple elements in an iModel. This problem is not solved by the methodology discussed above. To solve this, Data Objects need to act as Models and store a set of references to EC instances.
If you want to chat about this, need help with your project, or have questions, criticisms, reach me at zachzhu2016@gmail.com : )
Notes
- Using Mapper is one approach to integrate external data. If one can control the iModel contents, another way to map data is to assign the primary key values of external data objects to be the values of FederationGuid for your chosen EC instances. It’s capable of handling both Controlled Data and Free Data. FederationGuid acts as a secondary identifier for EC instances. FederationGuid is superior to an ad-hoc property based mapping key like BuildingNumber since it is globally unique while BuildingNumber could be used in many contexts.