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:

Timeline histogram tree group
//<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:

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:

  • sum or add (default) - sum of group member values
  • min - minimum of group member values
  • max - maximum of group member values
  • count - 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:

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.