creative-connections/aurelia-bodylight-plugin

View on GitHub
src/elements/chartjs.js

Summary

Maintainability
F
1 wk
Test Coverage
import Chart from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import {bindable} from 'aurelia-framework';
import _ from 'lodash';

//returns array of numbers if contains comma, or number - int
export function myParseInt(str,raddix) {
  if (typeof str !== "string") return str;
  if (str.lastIndexOf(',') > 0) return str.split(',').map(x => parseInt(x, raddix));
  else return parseInt(str, raddix);
}

export class Chartjs {
  @bindable fromid;
  @bindable labels;
  @bindable refindex;
  @bindable refvalues;
  @bindable type='doughnut';
  @bindable maxdata=256;
  @bindable initialdata='';
  @bindable width=300;
  @bindable height=200;
  @bindable animate=false;
  @bindable id;
  @bindable ylabel;
  @bindable xlabel;
  @bindable convertors;
  @bindable verticalline=false;
  @bindable generatelabels=false;
  @bindable sectionid;  //id to listen addsection event
  @bindable responsive = false; //false - to keep width and height, true - to rescale

  @bindable throttle=200; //time to throttle chart update, if it is too much at once
  @bindable precision=4;
  @bindable min; //min for y axis - if chart has this axis
  @bindable max; //max for y axis - if chart has this axis
  @bindable babylonjs; //whether to integrate with 3d babylonjs
  @bindable canvasobj; //canvas obj name -
  @bindable colorsegmentindex=-2; //index to shift the color
  @bindable colorindex=0; //index to shift the color
  @bindable minichart;
  indexsection=0;
  datalabels=false; //may be configured by subclasses
  refindices;

  /**
   * initializes handlers for event processing - this is recommended way
   */
  constructor() {
    this.handleValueChange = e => {
      //sets data to dataset
      //apply value convert among all data
      let rawdata;
      if (this.refindices) {
        rawdata = this.refindices.map(x => e.detail.data[x]);
      } else
        rawdata = e.detail.data.slice(this.refindex, this.refendindex);
      //if convert operation is defined as array
      if (this.operation) {
        for (let i = 0; i < rawdata.length; i++) {
        //if particular operation is defined
          if (this.operation[i]) rawdata[i] = this.operation[i](rawdata[i]);
        }
      }
      this.chart.data.datasets[0].data = rawdata;
      this.updatechart();
    };
    this.handleReset = e => {
      console.log('handlereset2()');
      if (this.chart.data.datasets)
        for (let dataset of this.chart.data.datasets)
          if (dataset && dataset.data) dataset.data = [];
      if (this.chart.data.labels.length>0) this.chart.data.labels = [];
      if (this.sectionid) {
        this.chart.config.options.section = [];
        this.indexsection=0;
      }
      this.updatechart();
      //this.chart.config.options.section = [];

    };
    this.handleAddSection = e => {
      this.addSection(e.detail.label);
    };

    this.handleFMIAttached = e => {
      const fromel = document.getElementById(this.fromid);
      if (fromel) {
        fromel.addEventListener('fmidata', this.handleValueChange);
        fromel.addEventListener('fmireset', this.handleReset);
      } else {
        console.warn('fmi attached, but no element with id found:',this.fromid);
      }
    }
  }

  /**
   * Returns unique color per index- neighbouring colors are different using golden angle approximation
   * @param index
   * @returns {string} usable by CSS or DOM elements
   */
  //  const hue = (i - 1) * 137.508; // use golden angle approximation
  //  var color = `hsl(${hue},85%,91%)`;
  selectColor(index, saturation = 55, lightness = 55) {
    const hue = (index - 1) * 137.508; // use golden angle approximation
    return `hsl(${hue},${saturation}%,${lightness}%)`;
  }


  /**
   * process all attributes of <bdl-chart> component and sets appropriate settings of subesquent chartjs
   */
  bind() {
    //console.log('chartjs bind');
    if ((typeof this.refindex == 'string') && (this.refindex.indexOf(',')>0)) { this.refindices = this.refindex.split(',')}
    else {
      this.refindex = myParseInt(this.refindex, 10);
      this.refvalues = parseInt(this.refvalues, 10);
      this.refendindex = this.refindex + this.refvalues;
    }

    //empty plugins by default
    this.plugins = [];

    //configure convertors - used to convert units received from fmi
    if (this.convertors) {
      let convertvalues = this.convertors.split(';');
      let identity = x => x;
      this.operation = [];
      for (let i = 0; i < convertvalues.length; i++) {
        if (convertvalues[i].includes(',')) {
          //convert values are in form numerator,denominator contains comma ','
          let convertitems = convertvalues[i].split(',');
          if (convertitems[0] === '1' && convertitems[1] === '1') this.operation.push(identity);
          else {
            let numerator = parseFloat(convertitems[0]);
            let denominator = parseFloat(convertitems[1]);
            let addend = (convertitems.length > 2) ? parseFloat(convertitems[2]) : 0;
            this.operation.push(x => ((x * numerator / denominator) + addend));
          }
        } else {
          //convert values are in form of expression, do not contain comma
          if (convertvalues === '1/x') this.operation.push(x=> 1 / x);

          else {
            // for eval() security filter only allowed characters:
            // algebraic, digits, e, dot, modulo, parenthesis and 'x' and 'e' is allowed
            let expression = convertvalues[i].replace(/[^-\d/*+.()%xe]/g, '');
            console.log('chartjs bind(), evaluating expression:' + convertvalues[i] + ' securely filtered to :' + expression);
            // eslint-disable-next-line no-eval
            this.operation.push(x => eval(expression));
          }
        }
      }
    }

    //sets boolean value - if verticalline attribute is set
    if (typeof this.generatelabels === 'string') {
      this.generatelabels = this.generatelabels === 'true';
    }
    if (typeof this.minichart === 'string') this.minichart = (this.minichart === 'true');
    //sets color of each dataset as different as possible
    //and set initial data in chart
    //set labels - separated by comma
    if (this.labels) this.chlabels = this.labels.split(',');
    //else generate labels as 'variable 1' ...
    else {
      //this.chlabels = [...Array(this.refvalues)].map((_, i) => this.generatelabels ? `variable ${i}` : '');
      //this seems not to be correctly transpilled to ES5, therefore following generator ->
      this.chlabels = [];
      for (let i = 0; i < this.refvalues; i++) {
        let ilabel = this.generatelabels ? ('variable ' + i ) : '';
        this.chlabels.push(ilabel);
      }
    }

    this.colors = [];
    let mydatastr = this.initialdata.split(',');
    this.mydata = mydatastr.map(x => {return parseFloat(x);});
    if (this.refindices) this.refvalues = this.refindices.length;
    for (let i = 0; i < this.refvalues; i++) {
      if (!this.mydata[i]) {
        //this.mydata.push(0);
        //console.log('chartjs no data');
      }
      this.colors.push(this.selectColor(i+this.colorindex));
    }

    let datasets = [{
      data: this.mydata,
      backgroundColor: this.colors
    }];

    this.data = {
      labels: this.chlabels,
      datasets: datasets
    };

    //bind - string value to boolean
    if (typeof this.animate === 'string') {
      this.animate = this.animate === 'true';
    }
    if (typeof this.responsive === 'string') {
      this.responsive = this.responsive === 'true';
    }
    //set animation options
    let animopts1 = {
      animateScale: true,
      animateRotate: true,
      duration: 500
    };
    let animopts2 = {duration: 0};

    //select options based on attribute value - whether to animate or not
    let animopts = this.animate ? animopts1 : animopts2;

    //set labels for axes in chartjs opts
    let axisopts = {};
    if (this.ylabel) {
      axisopts.yAxes = [{
        scaleLabel: {
          display: true,
          labelString: this.ylabel
        }
      }];
    }
    if (this.xlabel) {
      axisopts.xAxes = [{
        scaleLabel: {
          display: true,
          labelString: this.xlabel
        }
      }];
    }
    if (this.minichart) {
      if (axisopts.xAxes) axisopts.xAxes[0].display = false;
      else axisopts.xAxes = [{display:false }];
      if (axisopts.yAxes) axisopts.yAxes[0].display = false;
      else axisopts.yAxes = [{display:false }];
    }

    //initialize options - used later by chartjs instance
    this.options = {
      live: true,
      responsive: this.responsive, //true - rescale, false - will keep canvas width and height
      legend: {
        display: !(this.minichart),
        position: 'top'
      },
      animation: animopts,
      tooltips: {
        position: 'nearest',
        mode: 'index',
        intersect: false,
        titleFontFamily: 'Open Sans',
        backgroundColor: 'rgba(0,0,0,0.3)',
        //titleFontColor: 'red',
        caretSize: 5,
        cornerRadius: 4,
        xPadding: 3,
        yPadding: 3,
        callbacks: {
          label: function(tooltipItem, data) {
            //let label = data.labels[tooltipItem.index];
            let value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];
            //console.log('chartjs tooltip, value',tooltipItem,value);
            if (typeof value === 'object') return value.x.toPrecision(4)+':'+value.y.toPrecision(4)
            if (typeof value === 'number') return value.toPrecision(4); //TODO this.precision is not accessible from here
            return value;
          }
        }
      },
      hover: {
        animationDuration: 0, //disable animation on hover - e.g. for tooltips
        intersect:false
      },
      scales: axisopts,
      babylondynamictexture : ""// name of global dynamictextureobj to call update()
    };
    //sets boolean value - if verticalline attribute is set
    if (typeof this.verticalline === 'string') {
      this.verticalline = this.verticalline === 'true';
    }

    if (typeof this.maxdata === 'string') {
      this.maxdata = parseInt(this.maxdata, 10);
    }

    //if sections are requested - define chartjs plugin to draw it in background
    if (this.sectionid) {
      this.options.section = [];
    }

    if (this.min) {
      //sets yscale min
      if (!this.options) this.options = {};
      if (!this.options.scales) this.options.scales = {};
      if (!this.options.scales.yAxes) this.options.scales.yAxes = [{}]; //chartjs 2.9.4
      if (!this.options.scales.yAxes[0].ticks) this.options.scales.yAxes[0].ticks = {}; //chartjs 2.9.4
      this.options.scales.yAxes[0].ticks.min = parseFloat(this.min);
    }
    if (this.max) {
      //sets yscale max
      if (!this.options) this.options = {};
      if (!this.options.scales) this.options.scales = {};
      if (!this.options.scales.yAxes) this.options.scales.yAxes = [{}]; //chartjs 2.9.4
      if (!this.options.scales.yAxes[0].ticks) this.options.scales.yAxes[0].ticks = {}; //chartjs 2.9.4
      this.options.scales.yAxes[0].ticks.max = parseFloat(this.max);
      //if (this.min) this.options.scales.yAxes[0].ticks.stepSize = (this.options.scales.yAxes[0].ticks.max - this.options.scales.yAxes[0].ticks.min) / 10;
    }

    this.tooltips = ['mousemove', 'touchstart', 'touchmove', 'click'];
    if (typeof this.colorindex === 'string') {
      this.colorindex = parseInt(this.colorindex, 10);
    }
    if (typeof this.colorsegmentindex === 'string') {
      this.colorsegmentindex = parseInt(this.colorsegmentindex, 10);
    }
    /*if (this.minichart) {
      this.options.plugins.legend.display = false
    }*/
  }

  /**
   * this is called when the DOM is attached to view - instantiate the chartjs and sets all necesary binding
   */
  attached() {
    //console.log('chartjs attached');
    //listening to custom event fmidata and fmireset
    const fromel = document.getElementById(this.fromid);
    if (fromel) {
      fromel.addEventListener('fmidata', this.handleValueChange);
      fromel.addEventListener('fmireset', this.handleReset);
    } else {
      console.warn('chartjs, null fromid element, waiting to be attached');
      document.addEventListener('fmiattached',this.handleFMIAttached);
    }

    if (this.sectionid) {
      const sectionel = document.getElementById(this.sectionid);
      if (sectionel) sectionel.addEventListener('addsection', this.handleAddSection);
      else console.log('chartjs WARNING, null sectionid element');
    }

    //unregister
    Chart.plugins.unregister(ChartDataLabels);

    //for verticalline option - register controller for BdlChartjs
    if (this.verticalline) {
      Chart.defaults.LineWithLine = Chart.defaults.line;
      Chart.controllers.LineWithLine = Chart.controllers.line.extend({
        draw: function(ease) {
          Chart.controllers.line.prototype.draw.call(this, ease);
          if (this.chart.tooltip._active && this.chart.tooltip._active.length) {
            let activePoint = this.chart.tooltip._active[0];
            let ctx = this.chart.ctx;
            let x = activePoint.tooltipPosition().x;
            let topY = this.chart.legend.bottom;
            let bottomY = this.chart.chartArea.bottom;

            // draw line
            ctx.save();
            ctx.beginPath();
            ctx.moveTo(x, topY);
            ctx.lineTo(x, bottomY);
            ctx.lineWidth = 1;
            ctx.strokeStyle = '#555';
            ctx.stroke();
            ctx.restore();
          }
        }
      });
    }

    //for sections register chartjs plugin
    if (this.sectionid) {
      Chart.pluginService.register({
        beforeDraw: function(chart, easing) {
          if (chart.config.options.section && chart.config.options.section.length > 0) {
            let ctx = chart.chart.ctx;
            let chartArea = chart.chartArea;
            let meta = chart.getDatasetMeta(0);
            let i;
            ctx.save();
            //console.log('chartjs sections', chart.config.options.section);
            if (meta.data.length == 0) return;
            //first section

            for (i = 1; i < chart.config.options.section.length; i++) {
              //console.log('chartjs sectionplugin:i, section[i-1], section[1],start,stop)', i, chart.config.options.section[i - 1],chart.config.options.section[i]);
              const startindex = chart.config.options.section[i - 1].index;
              const stopindex = chart.config.options.section[i].index;
              if (startindex>=meta.data.length) continue;
              if (stopindex>=meta.data.length) continue;
              let start = meta.data[startindex]._model.x;
              let stop  = meta.data[stopindex]._model.x;
              /*const hue = (i - 1) * 137.508; // use golden angle approximation
              ctx.fillStyle = `hsl(${hue},85%,91%)`;
               */

              //bar
              ctx.fillStyle = chart.config.options.section[i - 1].color;
              ctx.fillRect(start, chartArea.top, stop - start, chartArea.bottom - chartArea.top);
              //label
              //ctx.translate(start, chartArea.top);
              //ctx.rotate(Math.PI / 2);
              ctx.save();
              ctx.translate(start, chartArea.top);
              ctx.rotate(90 * Math.PI / 180);
              ctx.fillStyle = '#aaa';
              ctx.font = '12px Helvetica';
              ctx.fillText(chart.config.options.section[i - 1].label, 5, -5);//start, chartArea.top);
              ctx.restore();
            }
            ctx.restore();
            //console.log('last i',i);
            //last section
            i = chart.config.options.section.length;
            if ((i > 1) && (chart.config.options.section[i - 1].index < (meta.data.length - 1)) && (chart.config.options.section[i - 1].index < meta.data.length)) {
              //draw last section
              let start = meta.data[chart.config.options.section[i - 1].index]._model.x;
              let stop  = meta.data[meta.data.length - 1]._model.x;

              //console.log (start,stop);
              /*
              const hue = (i - 1) * 137.508; // use golden angle approximation
              var color = `hsl(${hue},85%,91%)`;
               */
              ctx.fillStyle = chart.config.options.section[i - 1].color;
              //console.log (chartArea);
              ctx.fillRect(start, chartArea.top, stop - start, chartArea.bottom - chartArea.top);
              ctx.save();
              ctx.translate(start, chartArea.top);
              ctx.rotate(90 * Math.PI / 180);
              ctx.fillStyle = '#aaa';
              ctx.font = '12px Helvetica';
              ctx.fillText(chart.config.options.section[i - 1].label, 5, -5);//start, chartArea.top);
              ctx.restore();
            }
          }
        }
      });
    }

    if (this.datalabels) {
      console.log('datalabels true ,setting plugin', this.datalabels);
      console.log('datalabels true ,setting plugin', this.datalabels);
      Chart.pluginService.register({
        afterDatasetsDraw: function(chartInstance, easing) {
          // To only draw at the end of animation, check for easing === 1
          //if (dataset && dataset.datalabels) {
          let ctx = chartInstance.chart.ctx;

          chartInstance.data.datasets.forEach(function(dataset, i) {
            if (dataset && dataset.datalabels) {
              let meta = chartInstance.getDatasetMeta(i);
              if (!meta.hidden) {
                meta.data.forEach(function(element, index) {
                  if (dataset.datalabels[index].length > 0) {
                    // Draw the text in black, with the specified font
                    ctx.fillStyle = '#aaa';
                    ctx.font = '12px Helvetica';

                    // Just naively convert to string for now
                    let dataString = dataset.datalabels[index];

                    // Make sure alignment settings are correct
                    ctx.textAlign = 'center';
                    ctx.textBaseline = 'middle';

                    let padding = 5;
                    let position = element.tooltipPosition();
                    ctx.fillText(dataString, position.x, position.y - (12 / 2) - padding);
                  }
                });
              }
            }
          });
        }
      });
    }

    //babylonjs bind
    /*if (typeof this.babylonjs === 'string') {
      //this.babylonjs = this.babylonjs === 'true';
    } else this.babylonjs = false;*/
    if (this.babylonjs) {
      this.options.babylondynamictexture = this.babylonjs
      Chart.plugins.register({
        beforeDraw: function(chartInstance) {
          var ctx = chartInstance.chart.ctx;
          //console.log('ctx before draw:')
          ctx.fillStyle = "white";
          ctx.fillRect(0, 0, chartInstance.chart.width, chartInstance.chart.height);
        },
        afterDraw: function(chartInstance) {
          var ctx = chartInstance.chart.ctx;
          //console.log('ctx after draw:')
          if (window[chartInstance.options.babylondynamictexture]) window[chartInstance.options.babylondynamictexture].update();
        }
      });
    }

    //canvasobj - if defined then use this object name to get canvas object -  otherwise the one from template

    let ctx = (this.canvasobj)? window[this.canvasobj] : this.chartcanvas.getContext('2d');
    //ctx may be null if canvasobj is not yet initialized.
    if (ctx) this.initChart(ctx); //init chart only if ctx is ready
    else {
      //add myself to lazyinitchart array
      if (!window.lazyInitChart) window.lazyInitChart = [];
      window.lazyInitChart.push(this);
    }
    /*
        //do lazy init of charts after third party canvas initialization
        if (window.lazyInitChart) {
            for (let obj in window.lazyInitChart) obj.initChart().bind(obj);
        }
     */

  }

  initChart(){
    let ctx = (this.canvasobj)? window[this.canvasobj] : this.chartcanvas.getContext('2d');
    initChart(ctx);
  }

  initChart(ctx){
    /*let that = this;
    if (window.lazyInitChart) {let that = window.lazyInitChart;}*/
    this.chart = new Chart(ctx, {
      plugins: this.plugins,
      type: this.type,
      data: this.data,
      options: this.options,
      tooltipEvents: this.tooltips
    });
    //register throttled update function
    if (typeof this.throttle === 'string') this.throttle = parseInt(this.throttle, 10);

    if (this.throttle>0) {//throttle
      this.updatechart = _.throttle(this.chart.update.bind(this.chart), this.throttle);
    } else {//directly call chart update
      this.updatechart = this.chart.update.bind(this.chart);
    }
    // console.log('chartjs data', this.data);
/*    //now delay tooltip
    let originalShowTooltip = that.chart.showTooltip;
    //let that.timeout;
    that.timeout=0;
    that.chart.showTooltip = function (activeElements) {
      let delay = (activeElements.length === 0) ? 2000 : 0;
      clearTimeout(that.timeout);
      that.timeout = setTimeout(function () {
        originalShowTooltip.call(that.chart, activeElements);
      }, delay);
    }

 */
  }

  /**
   * called when component is detached from view - remove event listeners - no need to update chart
   */
  detached() {
    if (document.getElementById(this.fromid)) {
      document.getElementById(this.fromid).removeEventListener('fmidata', this.handleValueChange);
      document.getElementById(this.fromid).removeEventListener('fmireset', this.handleReset);
    } else {
      console.log('chartjs WARNING, null fromid element,removing from global');
      document.removeEventListener('fmidata', this.handleValueChange);
      document.removeEventListener('fmireset', this.handleReset);
    }
    if (this.sectionid) {document.getElementById(this.sectionid).removeEventListener('addsection', this.handleAddSection);}
    document.removeEventListener('fmiattached',this.handleFMIAttached)
  }

  /**
   * asks for filename and creates blob with CSV data from chart which initiates web browser download dialog.
   * CSV -  time point per row
   */
  download() {
    //ask for filename
    let filename = prompt('File name (*.csv):', 'data.csv');
    if (filename) {
      //adds csv as extension
      if (!filename.endsWith('.csv')) filename = filename.concat('.csv');
      //labels first row
      let content = 'Time,' + this.labels + '\n';
      //transpose each row = variable in specific time
      for (let i = 0; i < this.chart.data.labels.length; i++) {
        let row = this.chart.data.labels[i];
        for (let j = 0; j < this.chart.data.datasets.length; j++) {
          row += ',' + this.chart.data.datasets[j].data[i];
        }
        content += row + '\n';
      }
      let blob = new Blob([content], {type: 'text/csv;charset=utf-8;'});
      saveAs(blob, filename);
    }
  }

  /**
   * asks for filename and creates blob with CSV data from chart which initiates web browser download dialog
   * CSV - variable values per row
   */
  downloadflat() {
    //ask for filename
    let filename = prompt('File name (*.csv):', 'data.csv');
    if (filename) {
      //adds csv as extension
      if (!filename.endsWith('.csv')) filename = filename.concat('.csv');
      //labels first row - each row is then all data per variable - transposition might be needed
      let content = 'variable name,values ...' + '\n';
      let labels = this.labels.split(',');
      // variable per row
      //chart labels - usually time
      content = content + 'Time,' + this.chart.data.labels.join(',') + '\n';
      //dataset data on other rows
      for (let i = 0; i < this.chart.data.datasets.length; i++) {content = content + labels[i] + ',' + this.chart.data.datasets[i].data.join(',') + '\n';}
      let blob = new Blob([content], {type: 'text/csv;charset=utf-8;'});
      saveAs(blob, filename);
    }
  }

  /**
   * Adds new section in chartarea - current last data in dataset
   */
  addSection(label = '') {
    this.indexsection++;
    if (!label) label = '';
    console.log('chartjs.addsection()', this.chart.data.labels.length - 1, label);
    let ind;
    //if (this.chart.data.labels.length>0) ind = 0
    //else
    ind = Math.max(0,this.chart.data.labels.length-1);
    this.chart.config.options.section.push({
      index: ind,
      color: this.selectColor((this.indexsection+this.colorsegmentindex), 85, 93),
      label: label
    });
  }
}