Timeline histogram
TimelineHistogram is a component that renders histogram charts in the time axis column for each store record. The charts are meant to visualize some values along the time axis. The component also automatically shows a special scale column to match the plotted values and supports TreeGroup and Group features out of the box:
//<code-header>
fiddle.title = 'Timeline histogram tree group';
//</code-header>
const histogram = new TimelineHistogram({
appendTo : targetElement,
startDate : new Date(2023, 0, 1),
endDate : new Date(2023, 0, 6),
autoHeight : true,
columns : [{
type : 'tree',
text : 'Name',
field : 'name'
}],
// define histogram value series
series : {
work : {
// display work values as bars
type : 'bar'
},
maxWork : {
// display maxWork values as outline
type : 'outline'
}
},
features : {
tree : true,
treeGroup : {
levels : [
// group records by city field
'city'
]
}
},
// grid data
store : new Store({
data : [
{
id : 1,
name : 'Mats',
city : 'Stockholm',
// the record histogram data
histogramData : [
{ work : 8, maxWork : 16 },
{ work : 18, maxWork : 12 },
{ work : 12, maxWork : 10 },
{ work : 13, maxWork : 16 },
{ work : 15, maxWork : 16 },
{ work : 1, maxWork : 6 }
]
},
{
id : 2,
name : 'Johan',
city : 'Stockholm',
histogramData : [
{ work : 15, maxWork : 16 },
{ work : 8, maxWork : 26 },
{ work : 12, maxWork : 6 },
{ work : 13, maxWork : 16 },
{ work : 18, maxWork : 16 },
{ work : 8, maxWork : 16 }
]
},
{
id : 3,
name : 'Arcady',
city : 'Omsk',
histogramData : [
{ work : 15, maxWork : 16 },
{ work : 18, maxWork : 9 },
{ work : 8, maxWork : 11 },
{ work : 12, maxWork : 16 },
{ work : 13, maxWork : 16 },
{ work : 10, maxWork : 5 }
]
}
]
})
});Please check "Grouping support" chapter for more details on grouping.
Configuring histogram series
Every record chart plots the distribution of numeric values as a series of bars. The component can display multiple series if needed. There is a series config used for specifying series to be displayed:
new TimelineHistogram({
series : {
income : {
type : 'bar'
},
expenses : {
type : 'bar'
}
}
});
A series can also be displayed not only as a set of bars but as an outline. This can be done by using the
corresponding type:
new TimelineHistogram({
series : {
maxExpenses : {
type : 'outline'
}
}
});
Providing series data
The component subclasses TimelineBase and thus it's a Grid with some timeline APIs. As any grid it displays rows for its store's records.
But for this component each store record should additionally be accompanied with histogram data.
Histogram data in a nutshell is an array of objects with properties having series values.
For example the following histogram has two configured series income and expenses:
const histogram = new TimelineHistogram({
series : {
income : {
type : 'bar'
},
expenses : {
type : 'bar'
}
}
});
Then an individual record's histogram data might look like this:
[
{
"income" : 100,
"expenses" : 55
},
{
"income" : 150,
"expenses" : 189
},
{
"income" : 110,
"expenses" : 115
},
{
"income" : 112,
"expenses" : 70
}
]
The property names above by default match the configured series identifiers, but that can be changed if needed by
specifying field:
const histogram = new TimelineHistogram({
series : {
income : {
type : 'bar',
field : 'f1'
},
expenses : {
type : 'bar',
field : 'f1'
}
}
});
and then the data would look like this:
[
{
"f1" : 100,
"f2" : 55
},
{
"f1" : 150,
"f2" : 89
},
{
"f1" : 110,
"f2" : 85
},
{
"f1" : 112,
"f2" : 70
}
]
The above histogram data will result in displaying four bars stretched to fill the whole time axis width.
There are two main ways to provide the data to the component. The first way is using a record field and the second one is using a custom function. And besides these ways it's also possible to inject data in event listeners. Please see below chapters for details.
Providing series data in a record field
The series data can be provided in a record field, as configured with the dataModelField config. In this case refreshing the histogram is done by updating the field value.
The default field name is histogramData, so combining the above snippets the code could look like this:
// making a store for the timeline histogram view
const store = new Store({
// use inline data
data : [
{
name : 'John Smith',
histogramData : [
{
income : 100,
expenses : 55
},
{
income : 150,
expenses : 89
},
{
income : 110,
expenses : 85
},
{
income : 112,
expenses : 70
}
]
}
]
})
const histogram = new TimelineHistogram({
appendTo : document.body,
// use our store as data source
store,
// add extra name column
columns : [
{
text : 'Name',
field : 'name'
}
],
series : {
income : {
type : 'bar'
},
expenses : {
type : 'bar'
}
}
});
The above snippet uses inline data loading which naturally can be replaced with loading via AJAX or some other appropriate way:
const store = new AjaxStore({
// URL of the script that should respond with the data (including the histogram one)
readUrl : 'https://my-cool-url',
autoLoad : true
});
const histogram = new TimelineHistogram({
// use our AJAX store as data source
store,
...
});
Providing series data with a function
If providing the data in a field is not flexible enough it can be done with a custom function.
There is a getRecordData config accepting a function that
should return histogram data for the provided record.
The function can be asynchronous if needed and in this case it should return a Promise resolved with the
requested histogram data. For example:
new TimelineHistogram({
// get the record histogram data dynamically from the server side
async getRecordData(record) {
const response = await fetch(`https://some.url/get-histogram-data?recordId=${record.$originalId}`);
return response.json();
},
...
});
Injecting data in a listener
One more place to provide or customize a record's histogram data is using a listener for the beforeHistogramDataCacheSet or the histogramDataCacheSet event.
The beforeHistogramDataCacheSet event is triggered before the data is cached and histogramDataCacheSet right after it's done. Besides the events triggering order the main difference is that the first event allows to completely replacing the data to be cached
new TimelineHistogram({
series : {
foo : {
type : 'bar',
field : 'f1'
}
},
...
listeners : {
beforeHistogramDataCacheSet(eventData) {
// completely replace the data for a specific record
if (eventData.record.id === 123) {
eventData.data = [
{ f1 : 10 },
{ f1 : 20 },
{ f1 : 30 },
{ f1 : 40 },
{ f1 : 50 },
{ f1 : 60 }
];
}
}
}
});
And listening to histogramDataCacheSet event doesn't provide that ability but still allows customizing the data:
new TimelineHistogram({
series : {
bar : {
type : 'bar',
field : 'bar'
},
halfOfBar : {
type : 'outline',
field : 'half'
}
},
...
listeners : {
histogramDataCacheSet({ data }) {
// add an extra series entry for each item
data.forEach(entry => {
entry.half = entry.bar / 2;
});
}
}
});
Displaying text values
The view can also display series values as text. To enable this supply a
getBarText function.
The component will render the values in TEXT elements indicated with b-bar-legend CSS class.
new TimelineHistogram({
series : {
foo : {
type : 'bar'
}
},
getBarText(datum, index, series, renderData) {
// display "foo" series value as text
return datum.foo;
},
...
});
And if you want to have full control of the displayed TEXT tag attributes you can use the
getBarTextDOMConfig function:
new TimelineHistogram({
getBarText(datum, index, series, renderData) {
return datum.foo;
},
// Place text at the top of the "work" bar
getBarTextDOMConfig(domConfig, datum, index, _series, renderData) {
// calculate y-position in percents
domConfig.y = `${100 * (1 - datum.work / this.topValue)}%`;
return domConfig;
},
...
});
Please note that the functions will be injected into the underlying Histogram
component that is used under the hood to render actual charts.
So this will refer to the Histogram instance, not this class instance.
Please use this.owner to get the view instance:
new TimelineHistogram({
series : {
foo : {
type : 'bar'
}
},
getBarText(datum, index, series, renderData) {
// get TimelineHistogram view instance
const view = this.owner;
...
},
...
});
The functions are called as part of cell rendering and their renderData argument provides the cell render data,
which allows getting the record being rendered (and some other data like cell element, row etc):
new TimelineHistogram({
series : {
foo : {
type : 'bar'
}
},
getBarText(datum, index, series, renderData) {
// render text for all records except the one with id=321
if (renderData.record.id !== 321) {
// default bar text
return datum.foo;
}
return '';
},
...
});
Displaying bar tooltips
The view has configs that allows displaying a tooltip when hovering histogram bars:
- showBarTip a boolean flag that allows toggling tooltip on/off
- barTooltipTemplate a function implementing the tooltip template
Here is an example of configuring the tooltip:
new TimelineHistogram({
series : {
foo : {
type : 'bar'
}
},
showBarTip : true,
barTooltipTemplate(tooltipContext) {
const { datum, record } = tooltipContext;
// do not display tooltip for record #321
if (record.id === 321) {
return '';
}
return `<div class="my-tooltip">${datum.foo}</div>`;;
},
...
});
Changing individual rows content dynamically
The component uses the Histogram widget under the hood to render the charts.
The underlying histogram widget can be configured at runtime if needed.
There is a beforeRenderHistogramRow event
fired before a row is rendered.
The event allows injecting custom logic to make changes to the displayed data, add an extra series or
to hide one of them. The event data includes a histogramConfig configuration object that will be applied
to the widget:
new TimelineHistogram({
...
listeners : {
beforeRenderHistogramRow(renderData) {
const { record, histogramConfig } = renderData;
// hide "foo" series for record #1
if (record.id == 1) {
histogramConfig.series.foo = false;
}
// add new "extraLine" series for record #2
if (record.id == 2) {
histogramConfig.series.extraLine = {
type : 'outline',
// the series should use data from "foo" field
field : 'foo'
};
}
}
}
});
Grouping support
The components supports TreeGroup and Group features and implements automatic rendering of histograms for group records. It's done by aggregating group members data to their parents. All the configured series values are summed up and corresponding histograms are displayed for group records.
That automatic aggregating can be turned off by setting the
aggregateHistogramDataForGroups config
to false. Then group records histogram data is retrieved the same way it's done for regular records (either by
reading dataModelField field or by calling
getRecordData function).
Customizing data aggregating
By default series values are summed up, but that can be changed by specifying the aggregate property on a series
definition. For example in the following snippet we configure the component so that groups income and expenses
collects the minimum and maximum of its group members income and expenses values
respectively:
const histogram = new TimelineHistogram({
series : {
income : {
type : 'bar',
aggregate : 'min'
},
expenses : {
type : 'bar',
aggregate : 'max'
}
}
});
Currently supported aggregate config values are:
sumoradd(default) - sum of group member valuesmin- minimum of group member valuesmax- maximum of group member valuescount- count of group member values (effectively count of the group child records)avg- average of group member values
If that is not enough there are a few hooks allowing customization of the aggregation process:
- aggregateDataEntry - a function called for each child data entry, meant to aggregate the entry values to the corresponding parent entry
- getDataEntryForAggregating - a function that returns a child entry for aggregating
- initAggregatedDataEntry - a function that returns a target parent entry to put aggregated values in.
Styling charts
Charts are rendered with SVG tags.
Each histogram bar is rendered as a separate RECT element and an outline is rendered as a single PATH element.
To provide custom styling for the bars one can provide a
getBarClass function. The function is meant to return CSS
classes for a bar.
For example in the following snippet we make a histogram showing two series called income an expenses.
And we decorate the bars having expenses value greater than income value with too-much-spent CSS class:
new TimelineHistogram({
series : {
income : {
type : 'bar'
},
expenses : {
type : 'bar'
}
},
getBarClass(series, _rectConfig, datum) {
// indicate bars entries having expenses greater than income with "too-much-spent" CSS class
if (datum.expenses > datum.income) {
return 'too-much-spent';
}
return '';
}
})
And if you want to have full control of the displayed bar element attributes you can use the
getBarDOMConfig function.
Here for example we add horizontal margins to the displayed bars by adjusting RECT elements width
and x attributes:
new TimelineHistogram({
getBarDOMConfig(series, domConfig, datum, index) {
// Let's add a 10% of width margins to the left & right of the cell
const xMargin = 0.1 * domConfig.width;
// reduce bar width by the margins size
domConfig.width -= 2 * xMargin;
// adjust x-coordinate by the margin
domConfig.x += xMargin;
// return the edited domConfig
return domConfig;
},
...
});
By default each bar element is also decorated with b-series-* CSS-class where * matches the identifier of the
bar series. Then we can make a rule to highlight expenses entries having too-much-spent class with red color:
/* display expenses bar red if expenses > income */
rect.b-series-expenses.too-much-spent {
fill : red;
}
Outline elements can also be styled:
path.b-series-expenses {
stroke : black;
}
And they also support a getOutlineClass function to provide CSS-classes dynamically:
new TimelineHistogram({
series : {
income : {
type : 'bar'
},
expenses : {
type : 'outline'
}
},
getOutlineClass(series, data) {
// indicate if some entry has expenses greater than income with "too-much-spent" CSS class
if (data.some(datum => datum.expenses > datum.income)) {
return 'too-much-spent';
}
return '';
}
});
Since a single path element represents the whole series the function is called once and all the series data is passed in its second argument.
Reordering bars z-order
Bar positions can be adjusted along the z-axis. It can be done with the dataset.order property which specifies values
elements are sorted by. This can be useful when a bigger bar completely hides an underlying one.
Here for example the histogram displays two series of bars work and travelTime (and travelTime bars go above
work bars by default). But we specify getBarDOMConfig
function to reverse bars when travelTime value is greater than work value:
new TimelineHistogram({
// configure histogram displayed series of values
series : {
work : {
type : 'bar'
},
travelTime : {
type : 'bar'
}
},
getBarDOMConfig(series, domConfig, datum, index) {
// If "work" value is less than "travelTime"
// reorder the bars
if (datum.work < datum.travelTime) {
switch (series.id) {
// "travelTime" value will go first
case 'travelTime':
domConfig.dataset.order = 1;
break;
// and "work" value will go second
case 'work':
domConfig.dataset.order = 2;
break;
}
}
// return the edited domConfig (it's important)
return domConfig;
},
...
});
Placing multiple series bars along X-axis
Having full control over bar DOM configuration allows to place different series bars sequentially along X-axis (instead of stacking on top of each other by default). Here is an example of doing this using the getBarDOMConfig function:
new TimelineHistogram({
// configure histogram displayed series of values
series : {
work : {
type : 'bar'
},
travelTime : {
type : 'bar'
}
},
getBarDOMConfig(series, domConfig, datum, index) {
// Let's add a 10% of width margins to the left & right of the cell
const xMargin = 0.1 * domConfig.width;
// We place bars next to each other so width should be twice less (since we have two bar series)
domConfig.width /= 2;
// also reduce bar width by the margin size
domConfig.width -= xMargin;
// Shift x-coordinate by the margin
// and shift "travelTime" by by the calculated width to place it next to "work" bar
domConfig.x += xMargin + (series.id === 'travelTime' ? 1 : 0) * domConfig.width;
// return the edited domConfig
return domConfig;
},
...
});
Toggling series visibility
Toggling an individual series visibility is easily achievable with some CSS help based on the fact that
all bars have a b-series-* CSS-class (where * is identifier of the series the bar represents).
For the expenses series mentioned above it's enough to add a CSS-rule like this:
.b-timeline-histogram.b-hide-expenses rect.b-series-expenses {
display: none;
}
Then hiding the series can be done by this code:
histogram.element.classList.add('b-hide-expenses');
And this code can be used to display the series back:
histogram.element.classList.remove('b-hide-expenses');
Of course this can also be achieved by removing certain series in a listener as described in the "Changing individual rows content dynamically" chapter. But the benefit of this approach is that it doesn't require refreshing the view rows, which means it's quicker.