Yet another Blog about information visualization. The JavaScript working in this page extends the script I wrote for generating table-of-contents, see my according Blog article.
The idea is to not have a separate table-of-contents but to have the document within the TOC, or, in other words, to have a document that is a TOC. This is possible only with expand-controls as used in an UI tree, else the document could not mimic a TOC. Have a look at the treeification example below (it was not only Daphne, there were also Philemon and Baucis :-).
This is chapter 1.
This is chapter 1.1.
This is chapter 1.1.1
This is chapter 1.1.2
This is chapter 2
This is chapter 2.1
This is chapter 2.1.1
This is chapter 2.1.1.1
This is chapter 2.1.1.2
This is chapter 2.1.1.3
This is chapter 2.1.2
This is chapter 2.2
This is chapter 2.3
This is chapter 2.3.1
This is chapter 2.3.2
This is chapter 2.3.2.1
This is chapter 3
This is chapter 3.1
This is chapter 4
You see just the top-level headings. You can choose which one you are interested in and expand it. When you decide to go into breadth first, you could expand another top-level heading. Or you go into detail and expand the first one further.
The contrary approach is to have the document initially fully expanded. You can skip chapters you are not interested in by collapsing them.
A further idea would be to have just the headers visible, not the chapter text. Wherever there is chapter text, an expand control would be there to make it visible. Haven't implemented this yet.
What is missing for sure are buttons to (1) fully expand and (2) fully collapse the document.
The remainder of this Blog will be about how this was implemented in JS.
And I will layout that as treeification, so please click on the left-side arrows to see text.
What I had in mind was to reuse the module introduced in my Blog article about table-of-contents generation, and some folding logic introduced in my article about folding, to generate a "folded document".
The essence of writing is rewriting. This also applies to software!
So the first thing to do was rewrite the TOC-generator to have overridable functions that can be used to change its behaviour. In this case I introduced a new function customizeHeading(level, heading, titleElement)
that gets called for every chapter heading found. Remember that the title element could be different from the heading element, e.g. the heading is the parent div
, and the title is some nested h3
element. To the title element the expand control must be attached, and the heading element determines the content to be folded by the expand control.
Another adaption was to split the too big parameter list of tableOfContents()
into parameters of statical and dynamical character. The staticals went up to the enclosing factory function, while the dynamicals stayed on tableOfContents()
, which is now tableOfContents(topElement, headingTags, tocContainer)
. And I added a statical parameter initiallyFolded
, which makes sense only for treeification, not for TOC-generation.
I also introduced a new function globalSettings()
that initially receives all defaults and settings used during the document processing. Extensions thus have the chance to know a little about what happens in the TOC generator.
To be able to reuse a JS module you must know at least its overridable functions, and what they are doing. Documentation of the base module helps a lot. Here is the start of the new module that builds upon the functionality of the TOC-generator module.
var that = {};
/**
* Factory producing content-tree creator instances.
* @param treeIndentInEm optional, for nested elements, the amount of tree-indentation in em,
* defaults to undefined.
* @param doTocNumbering optional, chapter numbers are prepended to TOC when true,
* defaults to true.
* @param doHeadingNumbering optional, chapter numbers are prepended to headings when true,
* defaults to true.
* @param chapterNumberSeparator optional, separator for chapter numbers,
* defaults to ".".
* @param doTrailingSeparator optional, when true chapter numbers will be
* "1.2." instead of "1.2", defaults to false.
* @param initiallyFolded optional, whether chapters should be initially collapsed,
* defaults to false.
* @return an instance that can treeify a document.
*/
that.create = function(
treeIndentInEm,
doTocNumbering,
doHeadingNumbering,
chapterNumberSeparator,
doTrailingSeparator,
initiallyFolded)
{
var foldingUtil = folding.create();
var tocUtil = tocCreator.create(
treeIndentInEm,
doTocNumbering,
doHeadingNumbering,
chapterNumberSeparator,
doTrailingSeparator);
var superTableOfContents = tocUtil.tableOfContents;
/** Overwritten to install expand controls into the document tree before terminating. */
tocUtil.tableOfContents = function(topElement, headingTags, tocContainer) {
var toReturn = superTableOfContents(topElement, headingTags, tocContainer);
installExpandControls();
return toReturn;
}
.....
};
return that;
The create()
function is the factory that produces instances you can call tableOfContents()
upon. It receives the statical parameters mentioned above. Inside there are two objects that are given to this snippet globally (e.g. through AMD factory arguments). First is folding
factory that we need to install expand controls. Second is tocCreator
which creates the TOC generator instance we will extend here. Remember that in JS you always need an instance to perform inheritance. The private variable tocUtil
receives that instance, and further code uses that instance to extend it with overrides and new functionality. Finally that instance is returned for calling the modified tableOfContents()
on it. (That function name does not meet the purpose any more, a convenience function should be introduced that delegates to it.)
The override of tableOfContents()
first calls superTableOfContents
to execute the document processing. Then, after having received tree information through overrides, it installs expand controls by using the foldingUtil
and some new functions that find the content to be folded.
Mind that calling super in JS functional inheritance requires at least two lines of code: storing the super-implementation into some variable, and then calling it from its override.
Some functions should not work like they do when treeifying the document. First there should not be a TOC, because the document itself will be like a TOC. Then we do not need hyperlinks from the TOC to the chapter heading. Finally we want to receive tree information from the document traversal, and we override the new function customizeHeading(level, heading, titleElement)
for that. Additionally we receive the ground-tree-level and the fact whether structure analysis is done by h1 - h6
elements in globalSettings()
.
var tree = [];
var levelByH1To6;
var groundLevel;
/** Overwritten to store global settings. */
tocUtil.globalSettings = function(headingsParam, topElementParam, groundLevelParam, headingTagsParam, levelByH1To6Param) {
levelByH1To6 = levelByH1To6Param;
groundLevel = groundLevelParam;
};
/** Overwritten to NOT insert TOC into document. */
tocUtil.createDefaultTocContainer = function() {
return document.createElement("div");
};
/** Overwritten to do nothing, nothing gets linked here. */
tocUtil.setHyperlinkIdToHeading = function() {
return undefined;
};
/** Overwritten to create just an LI item for chapter number counting. */
tocUtil.createTocItem = function() {
return tocUtil.createTocListItem();
};
/** Overwritten to build a tree from super's iterations through heading elements. */
tocUtil.customizeHeading = function(level, heading, titleElement) {
tree.push({
level: level,
heading: heading,
titleElement: titleElement
});
};
For storing tree information we hold the private instance-bound array tree
. Each tree node, ordered depth-first, will be in there as an object with heading, title and tree-level. After super's document iteration we will use this array to install folding, and it must stay untouched during the whole page lifetime, because it will be used any time an expand control is clicked to retrieve child elements to be shown or hidden.
So what further do we have to do here? Install the folding controls.
var installExpandControls = function() {
for (var i = 0; i < tree.length; i++) {
var t = tree[i];
installExpandControl(t.level, t.heading, t.titleElement);
}
};
Unfortunately it is quite complex to find the correct elements to show and hide on expand-control clicks. When you read my Blog about treetables you might remember that on "collapse" we make invisible all elements below the control, but on "expand" we must not make visible elements that are managed by other expand controls. Else the expansion state of child items will be lost.
To make it even harder we must distinguish between the chapter hierarchy and the HTML element hierarchy, which may be identical, but could be different. In case of h1 - h6
structuring a structural child might be a HTML sibling. And that kind of structuring is more popular than nesting div
or section
elements into each other.
So let's solve the easy problems first.
Following function shows how the tree
array is used to find a list of structural children, whereby that list contains children of any depth, meaning the whole sub-tree of the given heading, recursively.
var structuralChildren = function(heading) {
var structuralChildren = [];
var level;
for (var i = 0; i < tree.length; i++) {
var h = tree[i].heading;
var l = tree[i].level;
if (level !== undefined) {
if (l > level)
structuralChildren.push(tree[i]);
else if (l <= level)
return structuralChildren;
}
else if (h === heading) {
level = l;
}
}
return structuralChildren;
};
And this one finds the next structural sibling of a heading.
var nextStructuralSibling = function(heading) {
var level;
for (var i = 0; i < tree.length; i++) {
var h = tree[i].heading;
var l = tree[i].level;
if (level !== undefined && l <= level)
return h;
else if (h === heading)
level = l;
}
return undefined;
};
Mind that is legal to return undefined, because the last element won't have a next sibling.These two functions give us all we need to know about structural elements.
When an expand control is closed, we need to hide (1) direct children except the title element and any parent of it, and (2) in case the structural children are not HTML children, all siblings elements until the next structural sibling. The same is for opening an expand control, with the difference that we must retain the expansion states of children.
var directChildrenWithout = function(titleElement, heading) {
var directChildren = [];
for (var i = 0; i < heading.children.length; i++) {
var child = heading.children[i];
if ( ! domUtil.isElementOrParentOf(child, titleElement, heading) )
directChildren.push(child);
}
return directChildren;
};
This finds direct HTML children (not the whole sub-tree recursively), excluding the given title element and any of its parents.
The next function finds HTML sibling elements until a given stop-sibling.
var siblingsUntil = function(heading, stopElement) {
var siblings = [];
var inBranch = false;
var parent = heading.parentNode;
for (var i = 0; i < parent.children.length && parent.children[i] !== stopElement; i++) {
var sibling = parent.children[i];
if (inBranch)
siblings.push(sibling);
else if (sibling === heading)
inBranch = true;
}
return siblings;
};
var installExpandControl = function(level, heading, titleElement) {
var elementsToHide = getElementsToHide(heading, titleElement);
var childHeadings = structuralChildren(heading);
var content = function(displayed) {
if (displayed)
return getElementsToShow(elementsToHide, childHeadings);
else
return elementsToHide;
};
foldingUtil.connect(
titleElement,
content,
initiallyFolded === undefined ? false : initiallyFolded,
false);
if ( ! levelByH1To6 && treeIndentInEm !== undefined)
heading.style["padding-left"] = ""+((level - groundLevel) * treeIndentInEm)+"em";
};
This installs the expand controls, and indents the heading when possible and required. How folding
works is beyond the scope of this article. In short, it prepends an arrow to the given titleElement
, and connects the click callback of that arrow to a function that sets given content
visible or invisible. These content elements can be given as single element, as array of elements, or as a function that returns element(s). Here a function is used, that in the case of "setting displayed" returns what getElementsToShow(elementsToHide, childHeadings)
delivers, and in case of "setting not displayed" returns the array of elements to hide.
As a precondition for that, the function reads the list of elements to hide from getElementsToHide(heading, titleElement)
. Parts of that list will also be used in case of "setting displayed".
var getElementsToHide = function(heading, titleElement) {
var directChildren = directChildrenWithout(titleElement, heading);
var stopElement = nextStructuralSibling(heading);
var siblings = siblingsUntil(heading, stopElement);
var elementsToHide = [];
for (var i = 0; i < directChildren.length; i++)
elementsToHide.push(directChildren[i]);
for (var i = 0; i < siblings.length; i++)
elementsToHide.push(siblings[i]);
return elementsToHide;
};
This collects the direct children of the heading element, excluding the title, and all siblings until the next structural sibling.
Mind that no arrays are passed around. Any returned array is built from scratch, several arrays are joined into a new array. This is the safer way to program with lists or arrays.
var getElementsToShow = function(elementsToHide, childHeadings) {
var collapsedChildHeadings = [];
for (var i = 0; i < childHeadings.length; i++) {
var childHeading = childHeadings[i];
if ( ! foldingUtil.isExpanded(childHeading.titleElement) )
collapsedChildHeadings.push(childHeading);
}
var elementsToShow = [];
for (var j = 0; j < elementsToHide.length; j++) {
var elementToHide = elementsToHide[j];
var isBelowCollapsed = false;
for (var k = 0; k < collapsedChildHeadings.length && isBelowCollapsed === false; k++) {
var collapsedHeading = collapsedChildHeadings[k];
var elementsBelowCollapsed = getElementsToHide(collapsedHeading.heading, collapsedHeading.titleElement);
if (contains(elementsBelowCollapsed, elementToHide))
isBelowCollapsed = true;
}
if (isBelowCollapsed === false)
elementsToShow.push(elementToHide);
}
return elementsToShow;
};
var contains = function(array, element) {
for (var i = 0; i < array.length; i++)
if (array[i] === element)
return true;
return false;
};
This is the solution for the complexity when opening an expand control. It retains the expansion states of child elements.
The parameter elementsToHide
contains all elements that would be hidden when closing the control. This is filtered by means of the second parameter that contains all child headings (recursively) that also can expand or collapse content.
First those child headings are retrieved that are currently collapsed. Then the new array elementsToShow
is created. It receives all elements from elementsToHide
that are not below any of the collapsed child headings.
What has not been shown here are the folding
and domUtil
modules. You can find these in the folded full source on bottom of this page.
Here is a use case that calls the new module:
<script type="text/javascript">
"use strict";
var treeCreatorInstance = treeCreator.create(
1, /* indentation */
undefined, /* whether TOC items should have numbering, true/false */
false, /* whether chapter headings should have numbering, true/false */
undefined, /* chapter number separator different from "." */
undefined, /* whether chapter numbers should have a trailing separator, true/false */
true /* whether document tree should be initially collapsed, true/false */
);
var blogDiv = document.getElementById("blog");
treeCreatorInstance.tableOfContents(blogDiv);
</script>
For me there were several reasons why I wanted to reuse the TOC-generator module:
There are a lot of new ideas now to continue this work. One thing is making the initiallyFolded
parameter more flexible. It should be configurable for any chapter individually whether it is collapsed or not.
Click here to see full source code.
1 | "use strict"; |
On my homepage you can always see the current state of this project.
ɔ⃝ Fritz Ritzberger, 2015-05-15