-
Notifications
You must be signed in to change notification settings - Fork 355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[tree-table] Refactor tree-table, make data structures private #500
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,5 +2,8 @@ module.exports = { | |
extends: '@addepar', | ||
env: { | ||
es6: true | ||
}, | ||
rules: { | ||
'no-restricted-globals': 'off' | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,385 @@ | ||
/* global Ember */ | ||
import { get, set } from '@ember/object'; | ||
import { assert } from '@ember/debug'; | ||
|
||
import { computed } from '@ember-decorators/object'; | ||
import { addObserver } from '@ember/object/observers'; | ||
|
||
/** | ||
Genericizes `objectAt` so it can be run against a normal array or an Ember array | ||
|
||
@param {object|Array} arr | ||
@param {number} index | ||
@return {any} | ||
*/ | ||
function objectAt(arr, index) { | ||
assert('arr must be an instance of a Javascript Array or implement `objectAt`', Array.isArray(arr) || typeof arr.objectAt === 'function'); | ||
|
||
if (typeof arr.objectAt === 'function') { | ||
return arr.objectAt(index); | ||
} | ||
|
||
return arr[index]; | ||
} | ||
|
||
/** | ||
Given a list of ordered values and a target value, finds the index of | ||
the closest value which does not exceed the target value | ||
|
||
@param {Array<number>} values - the list of values | ||
@param {number} target - the index to find the closest value to | ||
@return {number} - the index of the value closest to the target | ||
*/ | ||
function closestLessThan(values, target) { | ||
let low = 0; | ||
let high = values.length - 1; | ||
|
||
while (low <= high) { | ||
let mid = Math.floor((high + low) / 2); | ||
|
||
if (target < values[mid]) { | ||
high = mid - 1; | ||
} else if (target > values[mid]) { | ||
low = mid + 1; | ||
} else { | ||
return mid; | ||
} | ||
} | ||
|
||
// low === high + 1, we always want the lower value | ||
return high; | ||
} | ||
|
||
/** | ||
Single node of a CollapseTree | ||
*/ | ||
class Node { | ||
constructor(value, parent) { | ||
assert('value must have an array of children', Array.isArray(get(value, 'children'))); | ||
|
||
set(this, 'value', value); | ||
|
||
if (parent) { | ||
// Changes to the value directly should properly update all computeds on this | ||
// node, but we need to manually propogate changes upwards to notify any other | ||
// watchers | ||
addObserver(this, 'length', () => Ember.propertyDidChange(parent, 'length')); | ||
} | ||
} | ||
|
||
/** | ||
Whether or not the node is leaf of the CollapseTree. A node is a leaf if | ||
the wrapped value's children have no children. If so, there is no need to | ||
create another level of nodes in the tree - true leaves of the passed in | ||
value tree don't require any custom logic, so we can index directly into | ||
the array of children in `objectAt`. | ||
|
||
@type boolean | ||
*/ | ||
@computed('value.children.@each.children.[]') | ||
get isLeaf() { | ||
return !get(this, 'value.children').some((child) => { | ||
let children = get(child, 'children'); | ||
|
||
return Array.isArray(children) && get(children, 'length') > 0; | ||
}); | ||
} | ||
|
||
/** | ||
The children of this node, if they exist. Children can be other nodes, or | ||
spans (arrays) of leaf value-nodes. For instance: | ||
|
||
``` | ||
A | ||
└── B | ||
├── C | ||
└── D | ||
└── E | ||
``` | ||
|
||
In this example, A would have the following children: | ||
|
||
``` | ||
children = [ | ||
[B, C], | ||
Node(D) | ||
]; | ||
``` | ||
|
||
This allows us to do a binary search on the list of children without | ||
creating a node for each span, arrays simply represent x-children in | ||
a segment before a given node. | ||
|
||
@type Array<Node|Array<object>> | ||
*/ | ||
@computed('value.children.[]', 'isLeaf') | ||
get children() { | ||
if (get(this, 'isLeaf')) { | ||
return null; | ||
} | ||
|
||
let valueChildren = get(this, 'value.children'); | ||
let children = []; | ||
let sliceStart = false; | ||
|
||
valueChildren.forEach((child, index) => { | ||
let grandchildren = get(child, 'children'); | ||
|
||
if (Array.isArray(grandchildren) && get(grandchildren, 'length') > 0) { | ||
if (sliceStart !== false) { | ||
children.push(valueChildren.slice(sliceStart, index)); | ||
sliceStart = false; | ||
} | ||
|
||
children.push(new Node(child, this)); | ||
} else if (sliceStart === false) { | ||
sliceStart = index; | ||
} | ||
}); | ||
|
||
if (sliceStart !== false) { | ||
children.push(valueChildren.slice(sliceStart)); | ||
} | ||
|
||
return children; | ||
} | ||
|
||
/** | ||
The length of the node. Branches in three directions: | ||
|
||
1. If the node is collapsed, then the length of the node is 1, the | ||
node itself. This means that the parent node will only index into | ||
this child if it is trying to get exactly the child node, | ||
effectively hiding its children. | ||
2. If the node is a leaf, then the length is the node itself plus the | ||
length of its value-children. | ||
3. Otherwise, the length is the sum of the lengths of its children. | ||
*/ | ||
@computed('value.collapsed', 'collapsed', 'value.children.[]', 'isLeaf') | ||
get length() { | ||
if (get(this, 'value.collapsed') === true || get(this, 'collapsed') === true) { | ||
return 1; | ||
} else if (get(this, 'isLeaf')) { | ||
return 1 + get(this, 'value.children.length'); | ||
} else { | ||
return 1 + get(this, 'children').reduce((sum, child) => sum + get(child, 'length'), 0); | ||
} | ||
} | ||
|
||
/** | ||
Calculates a list of the summation of offsets of children to run a binary | ||
search against. Given: | ||
|
||
``` | ||
A | ||
├── B | ||
│ ├── C | ||
│ └── D | ||
├── E(c) | ||
│ ├── F | ||
│ └── G | ||
└── H | ||
│ ├── I | ||
│ └── J | ||
│ └── K | ||
└── L | ||
└── M | ||
``` | ||
|
||
The offsetList for A would be: `[0, 3, 6, 10, 11]`. Each item in this | ||
list is the offset of the corresponding child, or the summation of the | ||
lengths of all children preceding it. It is effectively the starting | ||
index of that child. | ||
|
||
So, if I'm trying to find index 9 in A, which is item K (not counting A | ||
itself), then I'm going to want to traverse down H, which is the 3rd child. | ||
I run a binary search against these offsets, which are ordered, and find | ||
the closest starting index which is strictly less than 9, which is the 3rd | ||
index. I know I can then recurse down that node and I should eventually | ||
find the item I'm after. | ||
*/ | ||
@computed('length', 'isLeaf') | ||
get offsetList() { | ||
if (get(this, 'isLeaf')) { | ||
return null; | ||
} | ||
|
||
let offset = 0; | ||
let offsetList = []; | ||
|
||
for (let child of get(this, 'children')) { | ||
offsetList.push(offset); | ||
offset += get(child, 'length'); | ||
} | ||
|
||
return offsetList; | ||
} | ||
|
||
/** | ||
Finds the object at the given index, where an index n is defined as the n-th | ||
item visited during a depth first traversal of the tree. To do this, we either | ||
|
||
1. Return the current node at index 0 | ||
2. If the node is a leaf, return the value child at the corresponding index | ||
3. Otherwise, find the correct child to walk down to and call `objectAt` on it | ||
with a normalized index | ||
|
||
`objectAt` also tracks the depth to pass back as meta information, something | ||
that is useful for displaying the tree as a list. `index` and `depth` are | ||
normalized as we traverse the tree, every time you "pass" a node you subtract | ||
it from the index for the next `objectAt` call, and you add 1 to depth for | ||
every `objectAt` call. | ||
|
||
@param {number} index - the index to find | ||
@param {number} depth - the depth of the current node in iteration | ||
@return {{ value: object, depth: number }} | ||
*/ | ||
objectAt(index, depth) { | ||
assert( | ||
'index must be gte than 0 and less than the length of the node', | ||
index >= 0 && index < get(this, 'length') | ||
); | ||
|
||
// The first index in a node is the node itself, since nodes are addressable | ||
if (index === 0) { | ||
let value = get(this, 'value'); | ||
|
||
return { value, depth, toggleCollapse: this.toggleCollapse }; | ||
} | ||
|
||
// Passed this node, remove it from the index and go one level deeper | ||
index = index - 1; | ||
depth = depth + 1; | ||
|
||
if (get(this, 'isLeaf')) { | ||
let value = objectAt(get(this, 'value.children'), index); | ||
|
||
return { value, depth }; | ||
} | ||
|
||
let children = get(this, 'children'); | ||
let offsetList = get(this, 'offsetList'); | ||
let offsetIndex = closestLessThan(offsetList, index); | ||
|
||
index = index - offsetList[offsetIndex]; | ||
|
||
let child = children[offsetIndex]; | ||
|
||
if (Array.isArray(child)) { | ||
return { value: child[index], depth }; | ||
} | ||
|
||
return child.objectAt(index, depth); | ||
} | ||
|
||
toggleCollapse = () => { | ||
let value = get(this, 'value'); | ||
|
||
if (value.hasOwnProperty('collapsed')) { | ||
set(value, 'collapsed', !get(value, 'collapsed')); | ||
} else { | ||
set(this, 'collapsed', !get(this, 'collapsed')); | ||
} | ||
}; | ||
} | ||
|
||
/** | ||
The goal of the collapse tree is provide a data structure that: | ||
|
||
1. Given an index n, can find the n-th node visited in a depth-first-walk | ||
of the tree | ||
2. Can "hide" or "collapse" nodes, so that their children are not walked | ||
|
||
So given a tree like this, where the (c) annotation means "collapsed": | ||
|
||
``` | ||
A | ||
├── B | ||
│ ├── C | ||
│ └── D | ||
├── E(c) | ||
│ ├── F | ||
│ └── G | ||
└── H | ||
│ ├── I | ||
│ └── J | ||
│ └── K | ||
└── L | ||
``` | ||
|
||
`objectAt(0) === A`, `objectAt(2) === C`, `objectAt(4) === E`, and | ||
`objectAt(5) === H` | ||
|
||
We also want to wrap this structure around a pre-existing tree that is a much | ||
simpler POJO with the shape: | ||
|
||
```json | ||
{ | ||
collapsed: false, | ||
children: [{ | ||
collapsed: true, | ||
children: [] | ||
}] | ||
} | ||
``` | ||
|
||
This allows us to provide a simple API to users while being able to index into | ||
their tree quickly and turn it into a list/table representation, without exposing | ||
any internal implementation details. | ||
|
||
To do this, each node in the tree has a `length` equal to the lengths of its | ||
children, and we do a binary search of each layer of the tree to find the closest | ||
node to the index. We traverse downward until we have the correct node, getting | ||
there in O(log(n)) time at worst (where n is the average number of nodes in a layer). | ||
|
||
Whenever a level of the tree changes (e.g. a node is added or removed) we must | ||
rebuild the subtree for that level. In order to keep tree construction and | ||
allocation costs low, we also do not create nodes for leaf children, since there is | ||
no need - they are length 1 and have no children, so no custom. Our tree saves an | ||
order of magnitude of space and allocation costs this way. | ||
*/ | ||
export default class CollapseTree { | ||
constructor(tree) { | ||
// This allows the tree to handle a root of a single node, or a root of an array | ||
// of nodes. If the given root was an array of nodes it "hides" the first node | ||
// by wrapping it in a fake root, and then skipping the root in `objectAt` calls. | ||
this.rootIsArray = Array.isArray(tree); | ||
|
||
if (this.rootIsArray === true) { | ||
this.root = new Node({ children: tree }); | ||
} else { | ||
this.root = new Node(tree); | ||
} | ||
|
||
// Whenever the root node's length changes we need to propogate the change to | ||
// users of the tree, and since the tree is meant to work like an array we should | ||
// trigger a change on the `[]` key as well. | ||
addObserver(this, 'length', () => Ember.propertyDidChange(this, '[]')); | ||
} | ||
|
||
/** | ||
|
||
@param {number} index - the index to find | ||
@return {{ value: object, depth: number }} | ||
*/ | ||
objectAt(index) { | ||
if (this.rootIsArray) { | ||
// If the root was an array, we added a "fake" top level node. Skip this node | ||
// by adding one to the index, and "subtracting" one from the depth. | ||
return this.root.objectAt(index + 1, -1); | ||
} | ||
|
||
return this.root.objectAt(index, 0); | ||
} | ||
|
||
/** | ||
Normalized length of the tree | ||
|
||
@type {number} | ||
*/ | ||
@computed('root.length') | ||
get length() { | ||
// If the root was an array, remove its fake wrapper node from the length count | ||
return this.rootIsArray ? get(this, 'root.length') - 1 : get(this, 'root.length'); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this create memleak when Node is no longer used?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Observers are instance state and will be garbage collected along with the instance, so long as there are no other references to the instance.