We recently built an analytics engine for MIT Media Lab using Ember.js, Rails, and D3.js. The project involved querying the MIT’s servers for data and using D3 to visualize the streams of data that returned from the query. Each of those streams could be operated on (e.g. finding the average), which would render a new stream showing the altered data.
How We Built The Tree Structure
We built a tree structure where the root node was the initial stream from the query, and child nodes were new streams which were generated as a result of a specific operation applied to a parent node. Sibling nodes — nodes that have the same parent — could be created as a result of applying multiple operations on the same parent node.
To accomplish this, we created a node model, which has many of itself (children), and belongs to itself (parent). In modeling terms, this is called a reflexive association.
export default DS.Model.extend({
// other domain logic
children: DS.hasMany("nodes", { inverse: "parent" }),
parent: DS.belongsTo("node", { inverse: "children" })
});
Recursive Calling of a Component
Rendering a tree structure of unknown size can be done by using recursion. The best way to handle this in Ember is to create a component — representing a node on a tree — that recursively calls itself. In the template that retrieved the initial data stream, we called the component that began the root of the tree.
{{stream-node node=model.rootNode}}
At the bottom of the {{stream-node}}
component’s template, we iterated through
the children on the model — the node — and rendered a new {{stream-node}}
component
for each.
{{#each displayedChildren as |displayedChild|}}
{{stream-node node=displayedChild stackedSiblings=stackedChildren}}
{{/each}}
And in our {{stream-node}}
component:
export default Ember.Component.extend({
// other domain logic
displayedChildren: function() {
return this.get("node.children").filter(function(node) {
return node.get("isTopChild");
});
}.property("node.{children,topChild}"),
});
A few things are happening here. Every time we call {{stream-node}}
, we pass
in the new stream data as node=…
. We can now use the same functionality on all
the nodes while only defining that functionality once in the {{stream-node}}
component.
The tree was represented vertically down the page. Each child node was placed
below the parent node. Whenever a node had siblings, they were displayed as a
stack, and the topChild()
was the node that appeared at the top of the stack.
We had a separate page for comparing the sibling nodes, and the compare view
could alter what the top child was.
topChild
is an attribute on the Node model rather than the component because
it is part of the domain logic, in which the server uses to figure out what the
active leaf nodes are for a given tree. The topChild
value is also used in
many places within the app, and therefore cannot be encapsulated in a single
component.
Because of the association on the parent node to the child node, the parent node
can validate that only one of its children has the topChild
attribute set to
true. This also gave us the benefit that when the model changes (adding children
or altering the topChild
), the computed properties will handle the update and
display the proper information.
Because we have the property("node.children")
on displayedChildren()
function, any time a new child was added to a node, Ember automatically created
a new {{stream-node}}
component and appended the appropriate node at the
bottom of the page.
When creating a new child node, all we needed to do to ensure that Ember would create the new component automatically was push the new node on the children array of the parent node:
newNode.save().then((node) => {
parentNode.get("children").addObject(node);
});
Bubbling and Deleting Nodes
Bubbling up events/actions in components is different than bubbling up via controller/routes. Controller/route bubbling happens automatically. Components are isolated, which means that when bubbling up actions is done manually. The name of the action in the controller/route that the event ultimately bubbles up to needs to be assigned as an attribute on the component so that Ember knows which function in the actions hash should handle this event.
The delete action is triggered within the stream-node template.
<a href="#" class="delete button button-tile button-default" {{action
"deleteNode" node}}>
</a>
There are two ways to assign this attribute. One is on the component call in the
template {{stream-node deleteNode="deleteNode" node=model.rootNode}}
, and the
other is to assign the attribute within the component itself:
export default Ember.Component.extend({
deleteNode: "deleteNode",
// other domain logic
actions: {
deleteNode: function(node) {
this.sendAction("deleteNode", node);
}
}
});
This would keep bubbling up through the component tree until it hit the action in the route:
export default Ember.Route.extend({
// other domain logic
actions: {
deleteNode: function(node) {
node.destroyRecord();
}
}
});
At this point, if you don’t delete the child nodes, then the orphaned nodes will remain in the store, but won’t be displayed. Once a child is deleted, the model of the parent node will update due to computed properties, and the components will re-render.
All in all, this project displayed the power of Ember in maintaining consistency in the data and updating computed properties whenever data changed. When we were tasked to implement multiple trees — a task we thought would take us a long time — it took 40 minutes to build, thanks to the heavy lifting of Ember.