Simulating per-series ChartJS data binding with a seriesTemplate

02 November 2016

At this time, our DevExtreme Charts don't support per-series data binding. I believe this would be a useful feature to have because it simplifies certain solutions considerably, and I hear our team is considering adding the feature in the future. Meanwhile, I was researching approaches to simulate the effects of per-series binding, and here is a workaround you can apply if you need this right now.

First, why does this requirement come up at all? I think the reason is mostly in the data structure you work with. When using data from structured storage, like a relational database system, your data would naturally be arranged along the lines of type separations in that storage.  As it happens, such normalized structures are also efficient for transfer, because they avoid redundancy. 

Here is such a data structure:

const data = [
    { place: "Los Angeles, CA, USA", values: [
        { month: "Jan", degreesC: 13.7 },
        { month: "Feb", degreesC: 14.2 },
        { month: "Mar", degreesC: 14.4 },
        { month: "Apr", degreesC: 15.6 },
        { month: "May", degreesC: 17.0 },
        { month: "Jun", degreesC: 18.7 },
        { month: "Jul", degreesC: 20.6 },
        { month: "Aug", degreesC: 21.3 },
        { month: "Sep", degreesC: 21.0 },
        { month: "Oct", degreesC: 19.3 },
        { month: "Nov", degreesC: 16.4 },
        { month: "Dec", degreesC: 13.8 }] },
    // .... more places and associated lists here
];


With this data, you might want to create one chart series for each of the "places" on the top level, and then each series would be fed by the "values" array associated with the "place". If series-level binding were an option, you could write this:

$("#chart").dxChart({ 
  series: data.map(p => ({  
    name: p.place,
    type: "line",
    data: p.values,
    argumentField: "month",
    valueField: "degreesC"
  }))
});

For clarity, please note that this does not work! Unfortunately, data cannot be bound to the series, it has to be bound to the chart on the top level. 

In case you are unfamiliar with functional programming helpers like "map", you need to know that the helper "map" iterates over the list "data" and executes the function it is given for each element in that array. That function is shown here as a lambda expression, and it generates a series configuration object for each of the "place" elements in the data list, using the "place" name as the series name and assigning "place.values" to the hypothetical "data" property of the configuration object.

Now that we've seen what the problem is, and how great the future could be if we had per-series binding, here's the workaround I promised. The basic idea is that we can use the "seriesTemplate" option of the chart configuration to make the chart generate series automatically on the basis of the data. This is a kind of meta-data-binding feature - instead of pre-creating series at design time and filling those with data at runtime, we set up a series template and use the chart-bound data to determine which series are created in the first place.


Here is another example that does not quite work yet. It shows how the chart can be configured to accept the "seriesTemplate" option:

$("#chart").dxChart({ 
  dataSource: data,
  commonSeriesSettings: {
    argumentField: "month",
    valueField: "degreesC",
    type: "line"
  },
  seriesTemplate: {
    nameField: "place"
  }
});


The "seriesTemplate" feature is clever: it watches the data field given in "nameField" and creates a new series automatically for each distinct value in that  field. The series is then configured by the parameters given in the "commonSeriesSettings" option, setting up the series field bindings correctly.


The reason this setup doesn't work right now is that the chart expects all the fields mentioned in its configuration to be exist on the same "level" of the bound datasource. However, in the sample data only the "place" field exists on the top level, the other fields are nested in associated arrays. The remaining job is therefor to transform the data to a structure like this:

[
    { place: "Los Angeles, CA, USA", month: "Jan", degreesC: 13.7 },
    ...
]


To execute the transformation, I wrote this function:

function transformData(data) {
  return data.map(p => 
    p.values.map(v => ({
      place: p.place, month: v.month, degreesC: v.degreesC
    }))).reduce((r, v) => r.concat(v));
}


The "map" helper is used again here, starting with the "data" list. For each element in that list, the lambda expression with the parameter "p" is executed ("p" is short for "place"). For each "place", another "map" is called on its "values" array, so eventually the inner lambda expression with the parameter "v" is executed for each of the value items in the nested arrays. This lambda expression returns a new object with the desired structure, combining the top-level and nested values. Finally, the "reduce" call is used to combine the individual arrays that have been generated per "place" into one - if you use a library like underscore or lodash, you can use a helper called "flatten" instead to achieve the same result.

Now I only need to change the "dataSource" assignment of the chart to use this helper:

dataSource: transformData(data),
...


I have created a sample to demonstrate the approach described above:


no comments
No Comments

Please login or register to post comments.