You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@zeppelin.apache.org by mo...@apache.org on 2017/04/14 22:34:35 UTC

[1/3] zeppelin git commit: [ZEPPELIN-2217] AdvancedTransformation for Visualization

Repository: zeppelin
Updated Branches:
  refs/heads/master 2173b4013 -> 45cc8a9e8


http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-web/src/app/tabledata/advanced-transformation-util.test.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/tabledata/advanced-transformation-util.test.js b/zeppelin-web/src/app/tabledata/advanced-transformation-util.test.js
new file mode 100644
index 0000000..6fde659
--- /dev/null
+++ b/zeppelin-web/src/app/tabledata/advanced-transformation-util.test.js
@@ -0,0 +1,1746 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as Util from './advanced-transformation-util.js'
+
+/* eslint-disable max-len */
+const MockParameter = {
+  'floatParam': { valueType: 'float', defaultValue: 10, description: '', },
+  'intParam': { valueType: 'int', defaultValue: 50, description: '', },
+  'jsonParam': { valueType: 'JSON', defaultValue: '', description: '', widget: 'textarea', },
+  'stringParam1': { valueType: 'string', defaultValue: '', description: '', },
+  'stringParam2': { valueType: 'string', defaultValue: '', description: '', widget: 'input', },
+  'boolParam': { valueType: 'boolean', defaultValue: false, description: '', widget: 'checkbox', },
+  'optionParam': { valueType: 'string', defaultValue: 'line', description: '', widget: 'option', optionValues: [ 'line', 'smoothedLine', ], },
+}
+/* eslint-enable max-len */
+
+const MockAxis1 = {
+  'keyAxis': { dimension: 'multiple', axisType: 'key', },
+  'aggrAxis': { dimension: 'multiple', axisType: 'aggregator', },
+  'groupAxis': { dimension: 'multiple', axisType: 'group', },
+}
+
+const MockAxis2 = {
+  'singleKeyAxis': { dimension: 'single', axisType: 'key', },
+  'limitedAggrAxis': { dimension: 'multiple', axisType: 'aggregator', maxAxisCount: 2, },
+  'groupAxis': { dimension: 'multiple', axisType: 'group', },
+}
+
+const MockAxis3 = {
+  'customAxis1': { dimension: 'single', axisType: 'unique', },
+  'customAxis2': { dimension: 'multiple', axisType: 'value', },
+}
+
+const MockAxis4 = {
+  'key1Axis': { dimension: 'multiple', axisType: 'key', },
+  'key2Axis': { dimension: 'multiple', axisType: 'key', },
+  'aggrAxis': { dimension: 'multiple', axisType: 'aggregator', },
+  'groupAxis': { dimension: 'multiple', axisType: 'group', },
+}
+
+
+// test spec for axis, param, widget
+const MockSpec = {
+  charts: {
+    'object-chart': {
+      transform: { method: 'object', },
+      sharedAxis: true,
+      axis: JSON.parse(JSON.stringify(MockAxis1)),
+      parameter: MockParameter,
+    },
+
+    'array-chart': {
+      transform: { method: 'array', },
+      sharedAxis: true,
+      axis: JSON.parse(JSON.stringify(MockAxis1)),
+      parameter: {
+        'arrayChartParam0': { valueType: 'string', defaultValue: '', description: 'param0', },
+      },
+    },
+
+    'drillDown-chart': {
+      transform: { method: 'drill-down', },
+      axis: JSON.parse(JSON.stringify(MockAxis2)),
+      parameter: {
+        'drillDownChartParam0': { valueType: 'string', defaultValue: '', description: 'param0', },
+      },
+    },
+
+    'raw-chart': {
+      transform: { method: 'raw', },
+      axis: JSON.parse(JSON.stringify(MockAxis3)),
+      parameter: {
+        'rawChartParam0': { valueType: 'string', defaultValue: '', description: 'param0', },
+      },
+    },
+  },
+}
+
+// test spec for transformation
+const MockSpec2 = {
+  charts: {
+    'object-chart': {
+      transform: { method: 'object', },
+      sharedAxis: false,
+      axis: JSON.parse(JSON.stringify(MockAxis1)),
+      parameter: MockParameter,
+    },
+
+    'array-chart': {
+      transform: { method: 'array', },
+      sharedAxis: false,
+      axis: JSON.parse(JSON.stringify(MockAxis1)),
+      parameter: {
+        'arrayChartParam0': { valueType: 'string', defaultValue: '', description: 'param0', },
+      },
+    },
+
+    'drillDown-chart': {
+      transform: { method: 'drill-down', },
+      sharedAxis: false,
+      axis: JSON.parse(JSON.stringify(MockAxis1)),
+      parameter: {
+        'drillDownChartParam0': { valueType: 'string', defaultValue: '', description: 'param0', },
+      },
+    },
+
+    'array2Key-chart': {
+      transform: { method: 'array:2-key', },
+      sharedAxis: false,
+      axis: JSON.parse(JSON.stringify(MockAxis4)),
+      parameter: {
+        'drillDownChartParam0': { valueType: 'string', defaultValue: '', description: 'param0', },
+      },
+    },
+
+    'raw-chart': {
+      transform: { method: 'raw', },
+      sharedAxis: false,
+      axis: JSON.parse(JSON.stringify(MockAxis3)),
+      parameter: {
+        'rawChartParam0': { valueType: 'string', defaultValue: '', description: 'param0', },
+      },
+    },
+  },
+}
+
+/* eslint-disable max-len */
+const MockTableDataColumn = [
+  {'name': 'age', 'index': 0, 'aggr': 'sum',},
+  {'name': 'job', 'index': 1, 'aggr': 'sum',},
+  {'name': 'marital', 'index': 2, 'aggr': 'sum',},
+  {'name': 'education', 'index': 3, 'aggr': 'sum',},
+  {'name': 'default', 'index': 4, 'aggr': 'sum',},
+  {'name': 'balance', 'index': 5, 'aggr': 'sum',},
+  {'name': 'housing', 'index': 6, 'aggr': 'sum',},
+  {'name': 'loan', 'index': 7, 'aggr': 'sum',},
+  {'name': 'contact', 'index': 8, 'aggr': 'sum',},
+  {'name': 'day', 'index': 9, 'aggr': 'sum',},
+  {'name': 'month', 'index': 10, 'aggr': 'sum',},
+  {'name': 'duration', 'index': 11, 'aggr': 'sum',},
+  {'name': 'campaign', 'index': 12, 'aggr': 'sum',},
+  {'name': 'pdays', 'index': 13, 'aggr': 'sum',},
+  {'name': 'previous', 'index': 14, 'aggr': 'sum',},
+  {'name': 'poutcome', 'index': 15, 'aggr': 'sum',},
+  {'name': 'y', 'index': 16, 'aggr': 'sum',}
+]
+
+const MockTableDataRows1 = [
+  [ '44', 'services', 'single', 'tertiary', 'no', '106', 'no', 'no', 'unknown', '12', 'jun', '109', '2', '-1', '0', 'unknown', 'no' ],
+  [ '43', 'services', 'married', 'primary', 'no', '-88', 'yes', 'yes', 'cellular', '17', 'apr', '313', '1', '147', '2', 'failure', 'no' ],
+  [ '39', 'services', 'married', 'secondary', 'no', '9374', 'yes', 'no', 'unknown', '20', 'may', '273', '1', '-1', '0', 'unknown', 'no' ],
+  [ '33', 'services', 'single', 'tertiary', 'no', '4789', 'yes', 'yes', 'cellular', '11', 'may', '220', '1', '339', '4', 'failure', 'no' ],
+]
+
+/* eslint-enable max-len */
+
+describe('advanced-transformation-util', () => {
+  describe('getCurrent* funcs', () => {
+    it('should set return proper value of the current chart', () => {
+      const config  = {}
+      const spec = JSON.parse(JSON.stringify(MockSpec))
+      Util.initializeConfig(config, spec)
+      expect(Util.getCurrentChart(config)).toEqual('object-chart')
+      expect(Util.getCurrentChartTransform(config)).toEqual({method: 'object'})
+      // use `toBe` to compare reference
+      expect(Util.getCurrentChartAxis(config)).toBe(config.axis['object-chart'])
+      // use `toBe` to compare reference
+      expect(Util.getCurrentChartParam(config)).toBe(config.parameter['object-chart'])
+    })
+  })
+
+  describe('useSharedAxis', () => {
+    it('should set chartChanged for initial drawing', () => {
+      const config  = {}
+      const spec = JSON.parse(JSON.stringify(MockSpec))
+      Util.initializeConfig(config, spec)
+      expect(Util.useSharedAxis(config, 'object-chart')).toEqual(true)
+      expect(Util.useSharedAxis(config, 'array-chart')).toEqual(true)
+      expect(Util.useSharedAxis(config, 'drillDown-chart')).toBeUndefined()
+      expect(Util.useSharedAxis(config, 'raw-chart')).toBeUndefined()
+    })
+  })
+
+  describe('initializeConfig', () => {
+    const config  = {}
+    const spec = JSON.parse(JSON.stringify(MockSpec))
+    Util.initializeConfig(config, spec)
+
+    it('should set chartChanged for initial drawing', () => {
+      expect(config.chartChanged).toBe(true)
+      expect(config.parameterChanged).toBe(false)
+    })
+
+    it('should set panel toggles ', () => {
+      expect(config.panel.columnPanelOpened).toBe(true)
+      expect(config.panel.parameterPanelOpened).toBe(false)
+    })
+
+    it('should set version and initialized', () => {
+      expect(config.spec.version).toBeDefined()
+      expect(config.spec.initialized).toBe(true)
+    })
+
+    it('should set chart', () => {
+      expect(config.chart.current).toBe('object-chart')
+      expect(config.chart.available).toEqual([
+        'object-chart',
+        'array-chart',
+        'drillDown-chart',
+        'raw-chart',
+      ])
+    })
+
+    it('should set sharedAxis', () => {
+      expect(config.sharedAxis).toEqual({
+        keyAxis: [], aggrAxis: [], groupAxis: [],
+      })
+      // should use `toBe` to compare object reference
+      expect(config.sharedAxis).toBe(config.axis['object-chart'])
+      // should use `toBe` to compare object reference
+      expect(config.sharedAxis).toBe(config.axis['array-chart'])
+    })
+
+    it('should set paramSpecs', () => {
+      const expected = Util.getSpecs(MockParameter)
+      expect(config.paramSpecs['object-chart']).toEqual(expected)
+      expect(config.paramSpecs['array-chart'].length).toEqual(1)
+      expect(config.paramSpecs['drillDown-chart'].length).toEqual(1)
+      expect(config.paramSpecs['raw-chart'].length).toEqual(1)
+    })
+
+    it('should set parameter with default value', () => {
+      expect(Object.keys(MockParameter).length).toBeGreaterThan(0) // length > 0
+      for (let paramName in MockParameter) {
+        expect(config.parameter['object-chart'][paramName])
+          .toEqual(MockParameter[paramName].defaultValue)
+      }
+    })
+
+    it('should set axisSpecs', () => {
+      const expected = Util.getSpecs(MockAxis1)
+      expect(config.axisSpecs['object-chart']).toEqual(expected)
+      expect(config.axisSpecs['array-chart'].length).toEqual(3)
+      expect(config.axisSpecs['drillDown-chart'].length).toEqual(3)
+      expect(config.axisSpecs['raw-chart'].length).toEqual(2)
+    })
+
+    it('should prepare axis depending on dimension', () => {
+      expect(config.axis['object-chart']).toEqual({
+        keyAxis: [], aggrAxis: [], groupAxis: [],
+      })
+      expect(config.axis['array-chart']).toEqual({
+        keyAxis: [], aggrAxis: [], groupAxis: [],
+      })
+      // it's ok not to set single dimension axis
+      expect(config.axis['drillDown-chart']).toEqual({ limitedAggrAxis: [], groupAxis: [], })
+      // it's ok not to set single dimension axis
+      expect(config.axis['raw-chart']).toEqual({ customAxis2: [], })
+    })
+
+  })
+
+  describe('axis', () => {
+
+  })
+
+  describe('parameter:widget', () => {
+    it('isInputWidget', () => {
+      expect(Util.isInputWidget(MockParameter.stringParam1)).toBe(true)
+      expect(Util.isInputWidget(MockParameter.stringParam2)).toBe(true)
+
+      expect(Util.isInputWidget(MockParameter.boolParam)).toBe(false)
+      expect(Util.isInputWidget(MockParameter.jsonParam)).toBe(false)
+      expect(Util.isInputWidget(MockParameter.optionParam)).toBe(false)
+    })
+
+    it('isOptionWidget', () => {
+      expect(Util.isOptionWidget(MockParameter.optionParam)).toBe(true)
+
+      expect(Util.isOptionWidget(MockParameter.stringParam1)).toBe(false)
+      expect(Util.isOptionWidget(MockParameter.stringParam2)).toBe(false)
+      expect(Util.isOptionWidget(MockParameter.boolParam)).toBe(false)
+      expect(Util.isOptionWidget(MockParameter.jsonParam)).toBe(false)
+    })
+
+    it('isCheckboxWidget', () => {
+      expect(Util.isCheckboxWidget(MockParameter.boolParam)).toBe(true)
+
+      expect(Util.isCheckboxWidget(MockParameter.stringParam1)).toBe(false)
+      expect(Util.isCheckboxWidget(MockParameter.stringParam2)).toBe(false)
+      expect(Util.isCheckboxWidget(MockParameter.jsonParam)).toBe(false)
+      expect(Util.isCheckboxWidget(MockParameter.optionParam)).toBe(false)
+    })
+
+    it('isTextareaWidget', () => {
+      expect(Util.isTextareaWidget(MockParameter.jsonParam)).toBe(true)
+
+      expect(Util.isTextareaWidget(MockParameter.stringParam1)).toBe(false)
+      expect(Util.isTextareaWidget(MockParameter.stringParam2)).toBe(false)
+      expect(Util.isTextareaWidget(MockParameter.boolParam)).toBe(false)
+      expect(Util.isTextareaWidget(MockParameter.optionParam)).toBe(false)
+    })
+  })
+
+  describe('parameter:parseParameter', () => {
+    const paramSpec = Util.getSpecs(MockParameter)
+
+    it('should parse number', () => {
+      const params = { intParam: '3', }
+      const parsed = Util.parseParameter(paramSpec, params)
+      expect(parsed.intParam).toBe(3)
+    })
+
+    it('should parse float', () => {
+      const params = { floatParam: '0.10', }
+      const parsed = Util.parseParameter(paramSpec, params)
+      expect(parsed.floatParam).toBe(0.10)
+    })
+
+    it('should parse boolean', () => {
+      const params1 = { boolParam: 'true', }
+      const parsed1 = Util.parseParameter(paramSpec, params1)
+      expect(typeof parsed1.boolParam).toBe('boolean')
+      expect(parsed1.boolParam).toBe(true)
+
+      const params2 = { boolParam: 'false', }
+      const parsed2 = Util.parseParameter(paramSpec, params2)
+      expect(typeof parsed2.boolParam).toBe('boolean')
+      expect(parsed2.boolParam).toBe(false)
+    })
+
+    it('should parse JSON', () => {
+      const params = { jsonParam: '{ "a": 3 }', }
+      const parsed = Util.parseParameter(paramSpec, params)
+      expect(typeof parsed.jsonParam).toBe('object')
+      expect(JSON.stringify(parsed.jsonParam)).toBe('{"a":3}')
+    })
+
+    it('should not parse string', () => {
+      const params = { stringParam: 'example', }
+      const parsed = Util.parseParameter(paramSpec, params)
+      expect(typeof parsed.stringParam).toBe('string')
+      expect(parsed.stringParam).toBe('example')
+    })
+
+  })
+
+  describe('removeDuplicatedColumnsInMultiDimensionAxis', () => {
+    let config = {}
+
+    beforeEach(() => {
+      config = {}
+      const spec = JSON.parse(JSON.stringify(MockSpec))
+      Util.initializeConfig(config, spec)
+      config.chart.current = 'drillDown-chart' // set non-sharedAxis chart
+    })
+
+    it('should remove duplicated axis names in config when axis is not aggregator', () => {
+      const addColumn = function(config, col) {
+        const axis = Util.getCurrentChartAxis(config)['groupAxis']
+        axis.push(col)
+        const axisSpecs = Util.getCurrentChartAxisSpecs(config)
+        Util.removeDuplicatedColumnsInMultiDimensionAxis(config, axisSpecs[2])
+      }
+
+      addColumn(config, { name: 'columnA', aggr: 'sum', index: 0, })
+      addColumn(config, { name: 'columnA', aggr: 'sum', index: 0, })
+      addColumn(config, { name: 'columnA', aggr: 'sum', index: 0, })
+
+      expect(Util.getCurrentChartAxis(config)['groupAxis'].length).toEqual(1)
+    })
+
+    it('should remove duplicated axis names in config when axis is aggregator', () => {
+      const addColumn = function(config, value) {
+        const axis = Util.getCurrentChartAxis(config)['limitedAggrAxis']
+        axis.push(value)
+        const axisSpecs = Util.getCurrentChartAxisSpecs(config)
+        Util.removeDuplicatedColumnsInMultiDimensionAxis(config, axisSpecs[1])
+      }
+
+      config.chart.current = 'drillDown-chart' // set non-sharedAxis chart
+      addColumn(config, { name: 'columnA', aggr: 'sum', index: 0, })
+      addColumn(config, { name: 'columnA', aggr: 'aggr', index: 0, })
+      addColumn(config, { name: 'columnA', aggr: 'sum', index: 0, })
+
+      expect(Util.getCurrentChartAxis(config)['limitedAggrAxis'].length).toEqual(2)
+    })
+  })
+
+  describe('applyMaxAxisCount', () => {
+    const config = {}
+    const spec = JSON.parse(JSON.stringify(MockSpec))
+    Util.initializeConfig(config, spec)
+
+    const addColumn = function(config, value) {
+      const axis = Util.getCurrentChartAxis(config)['limitedAggrAxis']
+      axis.push(value)
+      const axisSpecs = Util.getCurrentChartAxisSpecs(config)
+      Util.applyMaxAxisCount(config, axisSpecs[1])
+    }
+
+    it('should remove duplicated axis names in config', () => {
+      config.chart.current = 'drillDown-chart' // set non-sharedAxis chart
+
+      addColumn(config, 'columnA')
+      addColumn(config, 'columnB')
+      addColumn(config, 'columnC')
+      addColumn(config, 'columnD')
+
+      expect(Util.getCurrentChartAxis(config)['limitedAggrAxis']).toEqual([
+        'columnC', 'columnD',
+      ])
+    })
+  })
+
+  describe('getColumnsFromAxis', () => {
+    it('should return proper value for regular axis spec (key, aggr, group)', () => {
+      const config = {}
+
+      const spec = JSON.parse(JSON.stringify(MockSpec))
+      Util.initializeConfig(config, spec)
+      const chart = 'object-chart'
+      config.chart.current = chart
+
+      const axisSpecs = config.axisSpecs[chart]
+      const axis = config.axis[chart]
+      axis['keyAxis'].push('columnA')
+      axis['keyAxis'].push('columnB')
+      axis['aggrAxis'].push('columnC')
+      axis['groupAxis'].push('columnD')
+      axis['groupAxis'].push('columnE')
+      axis['groupAxis'].push('columnF')
+
+      const column = Util.getColumnsFromAxis(axisSpecs, axis)
+      expect(column.key).toEqual([ 'columnA', 'columnB', ])
+      expect(column.aggregator).toEqual([ 'columnC', ])
+      expect(column.group).toEqual([ 'columnD', 'columnE', 'columnF', ])
+    })
+
+    it('should return proper value for custom axis spec', () => {
+      const config = {}
+      const spec = JSON.parse(JSON.stringify(MockSpec))
+      Util.initializeConfig(config, spec)
+      const chart = 'raw-chart' // for test custom columns
+      config.chart.current = chart
+
+      const axisSpecs = config.axisSpecs[chart]
+      const axis = config.axis[chart]
+      axis['customAxis1'] = ['columnA']
+      axis['customAxis2'].push('columnB')
+      axis['customAxis2'].push('columnC')
+      axis['customAxis2'].push('columnD')
+
+      const column = Util.getColumnsFromAxis(axisSpecs, axis)
+      expect(column.custom.unique).toEqual([ 'columnA', ])
+      expect(column.custom.value).toEqual([ 'columnB', 'columnC', 'columnD', ])
+    })
+  })
+
+  // it's hard to test all methods for transformation.
+  // so let's do behavioral (black-box) test instead of
+  describe('getTransformer', () => {
+
+    describe('method: raw', () => {
+      let config = {}
+      const spec = JSON.parse(JSON.stringify(MockSpec2))
+      Util.initializeConfig(config, spec)
+
+      it('should return original rows when transform.method is `raw`', () => {
+        const chart = 'raw-chart'
+        config.chart.current = chart
+
+        const rows = [ { 'r1': 1, }, ]
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, rows, axisSpecs, axis).transformer
+        const transformed = transformer()
+
+        expect(transformed).toBe(rows)
+      })
+    })
+
+    describe('array method', () => {
+      let config = {}
+      const chart = 'array-chart'
+      let ageColumn = null
+      let balanceColumn = null
+      let educationColumn = null
+      let martialColumn = null
+      let tableDataRows = []
+
+      beforeEach(() => {
+        const spec = JSON.parse(JSON.stringify(MockSpec2))
+        config = {}
+        Util.initializeConfig(config, spec)
+        config.chart.current = chart
+        tableDataRows = JSON.parse(JSON.stringify(MockTableDataRows1))
+        ageColumn = JSON.parse(JSON.stringify(MockTableDataColumn[0]))
+        balanceColumn = JSON.parse(JSON.stringify(MockTableDataColumn[5]))
+        educationColumn = JSON.parse(JSON.stringify(MockTableDataColumn[3]))
+        martialColumn = JSON.parse(JSON.stringify(MockTableDataColumn[2]))
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ '', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: [ 159, ], }
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(count)', () => {
+        ageColumn.aggr = 'count'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        let { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(count)', value: [ 4, ], }
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(avg)', () => {
+        ageColumn.aggr = 'avg'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(avg)', value: [ (44 + 43 + 39 + 33) / 4.0, ], }
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(max)', () => {
+        ageColumn.aggr = 'max'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(max)', value: [ 44, ], }
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(min)', () => {
+        ageColumn.aggr = 'min'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(min)', value: [ 33, ], }
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ '', ])
+        expect(groupNames).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: [ 159, ], },
+          { selector: 'balance(sum)', value: [ 14181, ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital', ])
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([ 'married', 'single', ])
+        expect(rows).toEqual([
+          { selector: 'married', value: [ 82, ], },
+          { selector: 'single', value: [ 77, ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 1 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital', ])
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([
+          'married / age(sum)', 'married / balance(sum)', 'single / age(sum)', 'single / balance(sum)',
+        ])
+        expect(rows).toEqual([
+          { selector: 'married / age(sum)', value: [ 82 ] },
+          { selector: 'married / balance(sum)', value: [ 9286 ] },
+          { selector: 'single / age(sum)', value: [ 77 ] },
+          { selector: 'single / balance(sum)', value: [ 4895 ] },
+        ])
+      })
+
+      it('should transform properly: 0 key, 2 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital.education', ])
+        expect(groupNames).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(selectors).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(rows).toEqual([
+          { selector: 'married.primary', value: [ '43' ] },
+          { selector: 'married.secondary', value: [ '39' ] },
+          { selector: 'single.tertiary', value: [ 77 ] },
+        ])
+      })
+
+      it('should transform properly: 1 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital')
+        expect(keyNames).toEqual([ 'married', 'single', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: [ 82, 77, ] },
+        ])
+      })
+
+      it('should transform properly: 2 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+        config.axis[chart].keyAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital.education')
+        expect(keyNames).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: [ '43', '39', 77, ] },
+        ])
+      })
+
+      it('should transform properly: 1 key, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital')
+        expect(keyNames).toEqual([ 'married', 'single', ])
+        expect(groupNames).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(selectors).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(rows).toEqual([
+          { selector: 'primary', value: [ '43', null, ] },
+          { selector: 'secondary', value: [ '39', null, ] },
+          { selector: 'tertiary', value: [ null, 77, ] },
+        ])
+      })
+    }) // end: describe('method: array')
+
+    describe('method: object', () => {
+      let config = {}
+      const chart = 'object-chart'
+      let ageColumn = null
+      let balanceColumn = null
+      let educationColumn = null
+      let martialColumn = null
+      const tableDataRows = JSON.parse(JSON.stringify(MockTableDataRows1))
+
+      beforeEach(() => {
+        const spec = JSON.parse(JSON.stringify(MockSpec2))
+        config = {}
+        Util.initializeConfig(config, spec)
+        config.chart.current = chart
+        ageColumn = JSON.parse(JSON.stringify(MockTableDataColumn[0]))
+        balanceColumn = JSON.parse(JSON.stringify(MockTableDataColumn[5]))
+        educationColumn = JSON.parse(JSON.stringify(MockTableDataColumn[3]))
+        martialColumn = JSON.parse(JSON.stringify(MockTableDataColumn[2]))
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ '', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([{ 'age(sum)': 44 + 43 + 39 + 33, }])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(count)', () => {
+        ageColumn.aggr = 'count'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([{ 'age(count)': 4, }])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(avg)', () => {
+        ageColumn.aggr = 'avg'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { 'age(avg)': (44 + 43 + 39 + 33) / 4.0, }
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(max)', () => {
+        ageColumn.aggr = 'max'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([{ 'age(max)': 44, }])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(min)', () => {
+        ageColumn.aggr = 'min'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([{ 'age(min)': 33, }])
+      })
+
+      it('should transform properly: 0 key, 0 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ '', ])
+        expect(groupNames).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(rows).toEqual([{ 'age(sum)': 159, 'balance(sum)': 14181, }])
+      })
+
+      it('should transform properly: 0 key, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital', ])
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([ 'married', 'single', ])
+        expect(rows).toEqual([
+          { single: 77, married: 82, }
+        ])
+      })
+
+      it('should transform properly: 0 key, 1 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital', ])
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([
+          'married / age(sum)', 'married / balance(sum)', 'single / age(sum)', 'single / balance(sum)',
+        ])
+        expect(rows).toEqual([{
+          'married / age(sum)': 82,
+          'single / age(sum)': 77,
+          'married / balance(sum)': 9286,
+          'single / balance(sum)': 4895,
+        }])
+      })
+
+      it('should transform properly: 0 key, 2 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital.education', ])
+        expect(groupNames).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(selectors).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(rows).toEqual([{
+          'married.primary': '43', 'married.secondary': '39', 'single.tertiary': 77,
+        }])
+      })
+
+      it('should transform properly: 1 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital')
+        expect(keyNames).toEqual([ 'married', 'single', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          { 'age(sum)': 82, marital: 'married', },
+          { 'age(sum)': 77, marital: 'single', },
+        ])
+      })
+
+      it('should transform properly: 2 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+        config.axis[chart].keyAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital.education')
+        expect(keyNames).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          { 'age(sum)': '43', 'marital.education': 'married.primary' },
+          { 'age(sum)': '39', 'marital.education': 'married.secondary' },
+          { 'age(sum)': 77, 'marital.education': 'single.tertiary' },
+        ])
+      })
+
+      it('should transform properly: 1 key, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital')
+        expect(keyNames).toEqual([ 'married', 'single', ])
+        expect(groupNames).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(selectors).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(rows).toEqual([
+          { primary: '43', secondary: '39', marital: 'married' },
+          { tertiary: 44 + 33, marital: 'single' },
+        ])
+      })
+    }) // end: describe('method: object')
+
+    describe('method: drill-down', () => {
+      let config = {}
+      const chart = 'drillDown-chart'
+      let ageColumn = null
+      let balanceColumn = null
+      let educationColumn = null
+      let martialColumn = null
+      const tableDataRows = JSON.parse(JSON.stringify(MockTableDataRows1))
+
+      beforeEach(() => {
+        const spec = JSON.parse(JSON.stringify(MockSpec2))
+        config = {}
+        Util.initializeConfig(config, spec)
+        config.chart.current = chart
+        ageColumn = JSON.parse(JSON.stringify(MockTableDataColumn[0]))
+        balanceColumn = JSON.parse(JSON.stringify(MockTableDataColumn[5]))
+        educationColumn = JSON.parse(JSON.stringify(MockTableDataColumn[3]))
+        martialColumn = JSON.parse(JSON.stringify(MockTableDataColumn[2]))
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ '', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: 44 + 43 + 39 + 33, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(count)', () => {
+        ageColumn.aggr = 'count'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(count)', value: 4, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(avg)', () => {
+        ageColumn.aggr = 'avg'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(avg)', value: (44 + 43 + 39 + 33) / 4.0, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(max)', () => {
+        ageColumn.aggr = 'max'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(max)', value: 44, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 1 aggr(min)', () => {
+        ageColumn.aggr = 'min'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, } = transformer()
+        expect(rows).toEqual([
+          { selector: 'age(min)', value: 33, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 0 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ '', ])
+        expect(groupNames).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: 159, drillDown: [  ], },
+          { selector: 'balance(sum)', value: 14181, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 0 key, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital', ])
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          {
+            selector: 'age(sum)',
+            value: 159,
+            drillDown: [
+              { group: 'married', value: 82 },
+              { group: 'single', value: 77 },
+            ],
+          },
+        ])
+      })
+
+      it('should transform properly: 0 key, 1 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital', ])
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([ 'age(sum)', 'balance(sum)' ])
+        expect(rows).toEqual([
+          {
+            selector: 'age(sum)',
+            value: 159,
+            drillDown: [
+              { group: 'married', value: 82 },
+              { group: 'single', value: 77 },
+            ],
+          },
+          {
+            selector: 'balance(sum)',
+            value: 14181,
+            drillDown: [
+              { group: 'married', value: 9286 },
+              { group: 'single', value: 4895 },
+            ],
+          },
+        ])
+      })
+
+      it('should transform properly: 0 key, 2 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('')
+        expect(keyNames).toEqual([ 'marital.education', ])
+        expect(groupNames).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          {
+            selector: 'age(sum)',
+            value: 159,
+            drillDown: [
+              { group: 'married.primary', value: '43' },
+              { group: 'married.secondary', value: '39' },
+              { group: 'single.tertiary', value: 77 },
+            ],
+          },
+        ])
+      })
+
+      it('should transform properly: 1 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital')
+        expect(keyNames).toEqual([ 'married', 'single', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'married', 'single', ])
+        expect(rows).toEqual([
+          { selector: 'married', value: 82, drillDown: [  ], },
+          { selector: 'single', value: 77, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 2 key, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+        config.axis[chart].keyAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital.education')
+        expect(keyNames).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(rows).toEqual([
+          { selector: 'married.primary', value: '43', drillDown: [  ], },
+          { selector: 'married.secondary', value: '39', drillDown: [  ], },
+          { selector: 'single.tertiary', value: 77, drillDown: [  ], },
+        ])
+      })
+
+      it('should transform properly: 1 key, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].keyAxis.push(martialColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, keyColumnName, keyNames, groupNames, selectors, } = transformer()
+
+        expect(keyColumnName).toEqual('marital')
+        expect(keyNames).toEqual([ 'married', 'single', ])
+        expect(groupNames).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(selectors).toEqual([ 'married', 'single', ])
+        expect(rows).toEqual([
+          {
+            selector: 'married',
+            value: 82,
+            drillDown: [
+              { group: 'primary', value: '43' },
+              { group: 'secondary', value: '39' },
+              { group: 'tertiary', value: null },
+            ],
+          },
+          {
+            selector: 'single',
+            value: 77,
+            drillDown: [
+              { group: 'primary', value: null },
+              { group: 'secondary', value: null },
+              { group: 'tertiary', value: 77 },
+            ],
+          },
+        ])
+      })
+    }) // end: describe('method: drill-down')
+
+    describe('method: array:2-key', () => {
+      let config = {}
+      const chart = 'array2Key-chart'
+      let ageColumn = null
+      let balanceColumn = null
+      let educationColumn = null
+      let martialColumn = null
+      let daysColumn = null
+      let pDaysColumn = null
+      const tableDataRows = JSON.parse(JSON.stringify(MockTableDataRows1))
+
+      beforeEach(() => {
+        const spec = JSON.parse(JSON.stringify(MockSpec2))
+        config = {}
+        Util.initializeConfig(config, spec)
+        config.chart.current = chart
+        ageColumn = JSON.parse(JSON.stringify(MockTableDataColumn[0]))
+        martialColumn = JSON.parse(JSON.stringify(MockTableDataColumn[2]))
+        educationColumn = JSON.parse(JSON.stringify(MockTableDataColumn[3]))
+        balanceColumn = JSON.parse(JSON.stringify(MockTableDataColumn[5]))
+        daysColumn = JSON.parse(JSON.stringify(MockTableDataColumn[9]))
+        pDaysColumn = JSON.parse(JSON.stringify(MockTableDataColumn[13]))
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key2Names, selectors, } = transformer()
+
+        expect(key1Names).toEqual([])
+        expect(key2Names).toEqual([])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: [ { aggregated: 44 + 43 + 39 + 33, }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 0 group, 1 aggr(count)', () => {
+        ageColumn.aggr = 'count'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key2Names, selectors, } = transformer()
+
+        expect(key1Names).toEqual([])
+        expect(key2Names).toEqual([])
+        expect(selectors).toEqual([ 'age(count)', ])
+        expect(rows).toEqual([
+          { selector: 'age(count)', value: [ { aggregated: 4, }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 0 group, 1 aggr(avg)', () => {
+        ageColumn.aggr = 'avg'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key2Names, selectors, } = transformer()
+
+        expect(key1Names).toEqual([])
+        expect(key2Names).toEqual([])
+        expect(selectors).toEqual([ 'age(avg)', ])
+        expect(rows).toEqual([
+          { selector: 'age(avg)', value: [ { aggregated: (44 + 43 + 39 + 33) / 4.0, }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 0 group, 1 aggr(max)', () => {
+        ageColumn.aggr = 'max'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key2Names, selectors, } = transformer()
+
+        expect(key1Names).toEqual([])
+        expect(key2Names).toEqual([])
+        expect(selectors).toEqual([ 'age(max)', ])
+        expect(rows).toEqual([
+          { selector: 'age(max)', value: [ { aggregated: 44, }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 0 group, 1 aggr(min)', () => {
+        ageColumn.aggr = 'min'
+        config.axis[chart].aggrAxis.push(ageColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key2Names, selectors, } = transformer()
+
+        expect(key1Names).toEqual([])
+        expect(key2Names).toEqual([])
+        expect(selectors).toEqual([ 'age(min)', ])
+        expect(rows).toEqual([
+          { selector: 'age(min)', value: [ { aggregated: 33, }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 0 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, groupNames, selectors, } = transformer()
+
+        expect(groupNames).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', 'balance(sum)', ])
+        expect(rows).toEqual([
+          { selector: 'age(sum)', value: [ { aggregated: 159 } ] },
+          { selector: 'balance(sum)', value: [ { aggregated: 14181 }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, groupNames, selectors, } = transformer()
+
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([ 'married', 'single', ])
+        expect(rows).toEqual([
+          { selector: 'married', value: [ { aggregated: 82 }, ] },
+          { selector: 'single', value: [ { aggregated: 77 }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 1 group, 2 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        balanceColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, groupNames, selectors, } = transformer()
+
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual([
+          'married / age(sum)', 'married / balance(sum)', 'single / age(sum)', 'single / balance(sum)',
+        ])
+        expect(rows).toEqual([
+          { selector: 'married / age(sum)', value: [ { aggregated: 82 }, ] },
+          { selector: 'married / balance(sum)', value: [ { aggregated: 9286 }, ] },
+          { selector: 'single / age(sum)', value: [ { aggregated: 77 }, ] },
+          { selector: 'single / balance(sum)', value: [ { aggregated: 4895 }, ] },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 0 key2, 2 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, groupNames, selectors, } = transformer()
+
+        expect(groupNames).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(selectors).toEqual([ 'married.primary', 'married.secondary', 'single.tertiary', ])
+        expect(rows).toEqual([
+          { selector: 'married.primary', value: [ { aggregated: '43' }, ] },
+          { selector: 'married.secondary', value: [ { aggregated: '39' }, ] },
+          { selector: 'single.tertiary', value: [ { aggregated: 77 }, ] },
+        ])
+      })
+
+      it('should transform properly: 1 key1, 0 key2, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].key1Axis.push(balanceColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key1ColumnName,
+          key2Names, key2ColumnName, groupNames, selectors, } = transformer()
+
+        expect(key1Names).toEqual([ '-88', '106', '4789', '9374' ])
+        expect(key1ColumnName).toEqual('balance')
+        expect(key2Names).toEqual([])
+        expect(key2ColumnName).toEqual('')
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          {
+            selector: 'age(sum)',
+            value: [
+              { aggregated: '43', key1: '-88' },
+              { aggregated: '44', key1: '106' },
+              { aggregated: '33', key1: '4789' },
+              { aggregated: '39', key1: '9374' },
+            ]
+          }
+        ])
+      })
+
+      it('should transform properly: 0 key1, 1 key2, 0 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].key2Axis.push(balanceColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key1ColumnName,
+          key2Names, key2ColumnName, groupNames, selectors, } = transformer()
+
+        expect(key1Names).toEqual([])
+        expect(key1ColumnName).toEqual('')
+        expect(key2Names).toEqual([ '-88', '106', '4789', '9374' ])
+        expect(key2ColumnName).toEqual('balance')
+        expect(groupNames).toEqual([ 'age(sum)', ])
+        expect(selectors).toEqual([ 'age(sum)', ])
+        expect(rows).toEqual([
+          {
+            selector: 'age(sum)',
+            value: [
+              { aggregated: '43', key2: '-88' },
+              { aggregated: '44', key2: '106' },
+              { aggregated: '33', key2: '4789' },
+              { aggregated: '39', key2: '9374' },
+            ]
+          },
+        ])
+      })
+
+      it('should transform properly: 1 key1, 0 key2, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].key1Axis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key1ColumnName,
+          key2Names, key2ColumnName, groupNames, selectors, } = transformer()
+
+        expect(key1Names).toEqual([ '-88', '106', '4789', '9374' ])
+        expect(key1ColumnName).toEqual('balance')
+        expect(key2Names).toEqual([])
+        expect(key2ColumnName).toEqual('')
+        expect(groupNames).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(selectors).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(rows).toEqual([
+          { selector: 'primary', value: [ { aggregated: '43', key1: '-88' }, ] },
+          { selector: 'secondary', value: [ { aggregated: '39', key1: '9374' }, ] },
+          {
+            selector: 'tertiary',
+            value: [
+              { aggregated: '44', key1: '106' },
+              { aggregated: '33', key1: '4789' },
+            ]
+          },
+        ])
+      })
+
+      it('should transform properly: 0 key1, 1 key2, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].key2Axis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key1ColumnName,
+          key2Names, key2ColumnName, groupNames, selectors, } = transformer()
+
+        expect(key1Names).toEqual([])
+        expect(key1ColumnName).toEqual('')
+        expect(key2Names).toEqual([ '-88', '106', '4789', '9374' ])
+        expect(key2ColumnName).toEqual('balance')
+        expect(groupNames).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(selectors).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(rows).toEqual([
+          { selector: 'primary', value: [ { aggregated: '43', key2: '-88' }, ] },
+          { selector: 'secondary', value: [ { aggregated: '39', key2: '9374' }, ] },
+          {
+            selector: 'tertiary',
+            value: [
+              { aggregated: '44', key2: '106' },
+              { aggregated: '33', key2: '4789' },
+            ]
+          },
+        ])
+      })
+
+      it('should transform properly: 1 key1, 1 key2, 1 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].key1Axis.push(pDaysColumn)
+        config.axis[chart].key2Axis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key1ColumnName,
+          key2Names, key2ColumnName, groupNames, selectors, } = transformer()
+
+        expect(key1Names).toEqual([ '-1', '147', '339', ])
+        expect(key1ColumnName).toEqual('pdays')
+        expect(key2Names).toEqual([ '-88', '106', '4789', '9374' ])
+        expect(key2ColumnName).toEqual('balance')
+        expect(groupNames).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(selectors).toEqual([ 'primary', 'secondary', 'tertiary', ])
+        expect(rows).toEqual([
+          {
+            selector: 'primary',
+            value: [ { aggregated: '43', key1: '147', key2: '-88' }, ]
+          },
+          {
+            selector: 'secondary',
+            value: [ { aggregated: '39', key1: '-1', key2: '9374' }, ]
+          },
+          {
+            selector: 'tertiary',
+            value: [
+              { aggregated: '44', key1: '-1', key2: '106' },
+              { aggregated: '33', key1: '339', key2: '4789' },
+            ]
+          },
+        ])
+      })
+
+      it('should transform properly: 1 key1, 1 key2, 2 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'sum'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].key1Axis.push(pDaysColumn)
+        config.axis[chart].key2Axis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(educationColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key1ColumnName,
+          key2Names, key2ColumnName, groupNames, selectors, } = transformer()
+
+        expect(key1Names).toEqual([ '-1', '147', '339', ])
+        expect(key1ColumnName).toEqual('pdays')
+        expect(key2Names).toEqual([ '-88', '106', '4789', '9374' ])
+        expect(key2ColumnName).toEqual('balance')
+        expect(groupNames).toEqual([ 'primary.married', 'secondary.married', 'tertiary.single', ])
+        expect(selectors).toEqual([ 'primary.married', 'secondary.married', 'tertiary.single', ])
+        expect(rows).toEqual([
+          {
+            selector: 'primary.married',
+            value: [ { aggregated: '43', key1: '147', key2: '-88'}, ]
+          },
+          {
+            selector: 'secondary.married',
+            value: [ { aggregated: '39', key1: '-1', key2: '9374' }, ]
+          },
+          {
+            selector: 'tertiary.single',
+            value: [
+              { aggregated: '44', key1: '-1', key2: '106' },
+              { aggregated: '33', key1: '339', key2: '4789' },
+            ]
+          },
+        ])
+      })
+
+      it('should transform properly: 1 key1, 1 key2, 2 group, 1 aggr(sum)', () => {
+        ageColumn.aggr = 'min'
+        daysColumn.aggr = 'max'
+        config.axis[chart].aggrAxis.push(ageColumn)
+        config.axis[chart].aggrAxis.push(daysColumn)
+        config.axis[chart].key1Axis.push(pDaysColumn)
+        config.axis[chart].key2Axis.push(balanceColumn)
+        config.axis[chart].groupAxis.push(martialColumn)
+
+        const axisSpecs = config.axisSpecs[chart]
+        const axis = config.axis[chart]
+        const transformer = Util.getTransformer(config, tableDataRows, axisSpecs, axis).transformer
+
+        const { rows, key1Names, key1ColumnName,
+          key2Names, key2ColumnName, groupNames, selectors, } = transformer()
+
+        expect(key1Names).toEqual([ '-1', '147', '339', ])
+        expect(key1ColumnName).toEqual('pdays')
+        expect(key2Names).toEqual([ '-88', '106', '4789', '9374' ])
+        expect(key2ColumnName).toEqual('balance')
+        expect(groupNames).toEqual([ 'married', 'single', ])
+        expect(selectors).toEqual(
+          [ 'married / age(min)', 'married / day(max)', 'single / age(min)', 'single / day(max)', ]
+        )
+        expect(rows).toEqual([
+          {
+            selector: 'married / age(min)',
+            value: [
+              { aggregated: '39', key1: '-1', key2: '9374' },
+              { aggregated: '43', key1: '147', key2: '-88' },
+            ]
+          },
+          {
+            selector: 'married / day(max)',
+            value: [
+              { aggregated: '20', key1: '-1', key2: '9374' },
+              { aggregated: '17', key1: '147', key2: '-88' },
+            ]
+          },
+          {
+            selector: 'single / age(min)',
+            value: [
+              { aggregated: '44', key1: '-1', key2: '106' },
+              { aggregated: '33', key1: '339', key2: '4789' },
+            ]
+          },
+          {
+            selector: 'single / day(max)',
+            value: [
+              { aggregated: '12', key1: '-1', key2: '106' },
+              { aggregated: '11', key1: '339', key2: '4789' },
+            ]
+          },
+        ])
+      })
+
+    }) // end: describe('method: array:2-key')
+
+  }) // end: describe('getTransformer')
+})
+

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-web/src/app/tabledata/advanced-transformation.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/tabledata/advanced-transformation.js b/zeppelin-web/src/app/tabledata/advanced-transformation.js
new file mode 100644
index 0000000..d754f4d
--- /dev/null
+++ b/zeppelin-web/src/app/tabledata/advanced-transformation.js
@@ -0,0 +1,230 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Transformation from './transformation';
+
+import {
+  getCurrentChart, getCurrentChartAxis, getCurrentChartParam,
+  serializeSharedAxes, useSharedAxis,
+  getCurrentChartAxisSpecs, getCurrentChartParamSpecs,
+  initializeConfig, resetAxisConfig, resetParameterConfig,
+  isAggregatorAxis, isGroupAxis, isKeyAxis, isSingleDimensionAxis,
+  removeDuplicatedColumnsInMultiDimensionAxis, applyMaxAxisCount,
+  isInputWidget, isOptionWidget, isCheckboxWidget, isTextareaWidget, parseParameter,
+  getTransformer,
+} from './advanced-transformation-util';
+
+const SETTING_TEMPLATE = 'app/tabledata/advanced-transformation-setting.html';
+
+export default class AdvancedTransformation extends Transformation {
+  constructor(config, spec) {
+    super(config);
+
+    this.columns = []; /** [{ name, index, comment }] */
+    this.props = {};
+    this.spec = spec
+
+    initializeConfig(config, spec);
+  }
+
+  emitConfigChange(conf) {
+    conf.chartChanged = false
+    conf.parameterChanged = false
+    this.emitConfig(conf)
+  }
+
+  emitChartChange(conf) {
+    conf.chartChanged = true
+    conf.parameterChanged = false
+    this.emitConfig(conf)
+  }
+
+  emitParameterChange(conf) {
+    conf.chartChanged = false
+    conf.parameterChanged = true
+    this.emitConfig(conf)
+  }
+
+  getSetting() {
+    const self = this; /** for closure */
+    const configInstance = self.config; /** for closure */
+
+    if (self.spec.initialized) {
+      self.spec.initialized = false
+      self.emitConfig(configInstance)
+    }
+
+    return {
+      template: SETTING_TEMPLATE,
+      scope: {
+        config: configInstance,
+        columns: self.columns,
+        resetAxisConfig: () => {
+          resetAxisConfig(configInstance)
+          self.emitChartChange(configInstance)
+        },
+
+        resetParameterConfig: () => {
+          resetParameterConfig(configInstance)
+          self.emitParameterChange(configInstance)
+        },
+
+        toggleColumnPanel: () => {
+          configInstance.panel.columnPanelOpened = !configInstance.panel.columnPanelOpened
+          self.emitConfigChange(configInstance)
+        },
+
+        toggleParameterPanel: () => {
+          configInstance.panel.parameterPanelOpened = !configInstance.panel.parameterPanelOpened
+          self.emitConfigChange(configInstance)
+        },
+
+        getAxisAnnotation: (axisSpec) => {
+          let anno = `${axisSpec.name}`
+          if (axisSpec.valueType) {
+            anno = `${anno} (${axisSpec.valueType})`
+          }
+
+          return anno
+        },
+
+        getAxisTypeAnnotation: (axisSpec) => {
+          let anno = `${axisSpec.axisType}`
+
+          let minAxisCount = axisSpec.minAxisCount
+          let maxAxisCount = axisSpec.maxAxisCount
+
+          if (isSingleDimensionAxis(axisSpec)) {
+            maxAxisCount = 1
+          }
+
+          let comment = ''
+          if (minAxisCount) { comment = `min: ${minAxisCount}` }
+          if (minAxisCount && maxAxisCount) { comment = `${comment}, `}
+          if (maxAxisCount) { comment = `${comment}max: ${maxAxisCount}` }
+
+          if (comment !== '') {
+            anno = `${anno} (${comment})`
+          }
+
+          return anno
+        },
+
+        getAxisTypeAnnotationColor: (axisSpec) => {
+          if (isAggregatorAxis(axisSpec)) {
+            return { 'background-color': '#5782bd' };
+          } else if (isGroupAxis(axisSpec)) {
+            return { 'background-color': '#cd5c5c' };
+          } else if (isKeyAxis(axisSpec)) {
+            return { 'background-color': '#906ebd' };
+          } else {
+            return { 'background-color': '#62bda9' };
+          }
+        },
+
+        useSharedAxis: (chartName) => { return useSharedAxis(configInstance, chartName) },
+        isGroupAxis: (axisSpec) => { return isGroupAxis(axisSpec) },
+        isKeyAxis: (axisSpec) => { return isKeyAxis(axisSpec) },
+        isAggregatorAxis: (axisSpec) => { return isAggregatorAxis(axisSpec) },
+        isSingleDimensionAxis: (axisSpec) => { return isSingleDimensionAxis(axisSpec) },
+        getSingleDimensionAxis: (axisSpec) => { return getCurrentChartAxis(configInstance)[axisSpec.name] },
+
+        chartChanged: (selected) => {
+          configInstance.chart.current = selected
+          self.emitChartChange(configInstance)
+        },
+
+        axisChanged: function(e, ui, axisSpec) {
+          removeDuplicatedColumnsInMultiDimensionAxis(configInstance, axisSpec)
+          applyMaxAxisCount(configInstance, axisSpec)
+
+          self.emitChartChange(configInstance)
+        },
+
+        aggregatorChanged: (colIndex, axisSpec, aggregator) => {
+          if (isSingleDimensionAxis(axisSpec)) {
+            getCurrentChartAxis(configInstance)[axisSpec.name].aggr = aggregator
+          } else {
+            getCurrentChartAxis(configInstance)[axisSpec.name][colIndex].aggr = aggregator
+            removeDuplicatedColumnsInMultiDimensionAxis(configInstance, axisSpec)
+          }
+
+          self.emitChartChange(configInstance)
+        },
+
+        removeFromAxis: function(colIndex, axisSpec) {
+          if (isSingleDimensionAxis(axisSpec)) {
+            getCurrentChartAxis(configInstance)[axisSpec.name] = null
+          } else {
+            getCurrentChartAxis(configInstance)[axisSpec.name].splice(colIndex, 1)
+          }
+
+          self.emitChartChange(configInstance)
+        },
+
+        isInputWidget: function(paramSpec) { return isInputWidget(paramSpec) },
+        isCheckboxWidget: function(paramSpec) { return isCheckboxWidget(paramSpec) },
+        isOptionWidget: function(paramSpec) { return isOptionWidget(paramSpec) },
+        isTextareaWidget: function(paramSpec) { return isTextareaWidget(paramSpec) },
+
+        parameterChanged: (paramSpec) => {
+
+          configInstance.chartChanged = false
+          configInstance.parameterChanged = true
+          self.emitParameterChange(configInstance)
+        },
+
+        parameterOnKeyDown: function(event, paramSpec) {
+          const code = event.keyCode || event.which;
+          if (code === 13 && isInputWidget(paramSpec)) {
+            self.emitParameterChange(configInstance)
+          } else if (code === 13 && event.shiftKey && isTextareaWidget(paramSpec)) {
+            self.emitParameterChange(configInstance)
+          }
+
+          event.stopPropagation() /** avoid to conflict with paragraph shortcuts */
+        },
+
+      }
+    }
+  }
+
+  transform(tableData) {
+    this.columns = tableData.columns; /** used in `getSetting` */
+    /** initialize in `transform` instead of `getSetting` because this method is called before */
+    serializeSharedAxes(this.config)
+
+    const conf = this.config
+    const chart = getCurrentChart(conf)
+    const axis = getCurrentChartAxis(conf)
+    const axisSpecs = getCurrentChartAxisSpecs(conf)
+    const param = getCurrentChartParam(conf)
+    const paramSpecs = getCurrentChartParamSpecs(conf)
+    const parsedParam = parseParameter(paramSpecs, param)
+
+   let { transformer, column, }  = getTransformer(conf, tableData.rows, axisSpecs, axis)
+
+    return {
+      chartChanged: conf.chartChanged,
+      parameterChanged: conf.parameterChanged,
+
+      chart: chart, /** current chart */
+      axis: axis, /** persisted axis */
+      parameter: parsedParam, /** persisted parameter */
+      column: column,
+
+      transformer: transformer,
+    }
+  }
+}

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-web/src/index.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/index.js b/zeppelin-web/src/index.js
index 820d2d6..5afc9ff 100644
--- a/zeppelin-web/src/index.js
+++ b/zeppelin-web/src/index.js
@@ -23,6 +23,7 @@ import './app/tabledata/transformation.js';
 import './app/tabledata/pivot.js';
 import './app/tabledata/passthrough.js';
 import './app/tabledata/columnselector.js';
+import './app/tabledata/advanced-transformation.js';
 import './app/visualization/visualization.js';
 import './app/visualization/builtins/visualization-table.js';
 import './app/visualization/builtins/visualization-nvd3chart.js';

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-zengine/src/main/java/org/apache/zeppelin/helium/HeliumBundleFactory.java
----------------------------------------------------------------------
diff --git a/zeppelin-zengine/src/main/java/org/apache/zeppelin/helium/HeliumBundleFactory.java b/zeppelin-zengine/src/main/java/org/apache/zeppelin/helium/HeliumBundleFactory.java
index d0e9b00..5bdb14c 100644
--- a/zeppelin-zengine/src/main/java/org/apache/zeppelin/helium/HeliumBundleFactory.java
+++ b/zeppelin-zengine/src/main/java/org/apache/zeppelin/helium/HeliumBundleFactory.java
@@ -55,6 +55,7 @@ public class HeliumBundleFactory {
   public static final String HELIUM_LOCAL_MODULE_DIR = "local_modules";
   public static final String HELIUM_BUNDLES_SRC_DIR = "src";
   public static final String HELIUM_BUNDLES_SRC = "load.js";
+  public static final String YARN_CACHE_DIR = "yarn-cache";
   public static final String PACKAGE_JSON = "package.json";
   public static final String HELIUM_BUNDLE_CACHE = "helium.bundle.cache.js";
   public static final String HELIUM_BUNDLE = "helium.bundle.js";
@@ -68,6 +69,7 @@ public class HeliumBundleFactory {
   private final File heliumLocalRepoDirectory;
   private final File heliumBundleDirectory;
   private final File heliumLocalModuleDirectory;
+  private final File yarnCacheDir;
   private ZeppelinConfiguration conf;
   private File tabledataModulePath;
   private File visualizationModulePath;
@@ -98,6 +100,7 @@ public class HeliumBundleFactory {
     this.heliumLocalRepoDirectory = new File(moduleDownloadPath, HELIUM_LOCAL_REPO);
     this.heliumBundleDirectory = new File(heliumLocalRepoDirectory, HELIUM_BUNDLES_DIR);
     this.heliumLocalModuleDirectory = new File(heliumLocalRepoDirectory, HELIUM_LOCAL_MODULE_DIR);
+    this.yarnCacheDir = new File(heliumLocalRepoDirectory, YARN_CACHE_DIR);
     this.conf = conf;
     this.defaultNpmRegistryUrl = conf.getHeliumNpmRegistry();
 
@@ -110,7 +113,7 @@ public class HeliumBundleFactory {
     gson = new Gson();
   }
 
-  void installNodeAndNpm() {
+  void installNodeAndNpm() throws TaskRunnerException {
     if (nodeAndNpmInstalled) {
       return;
     }
@@ -126,6 +129,8 @@ public class HeliumBundleFactory {
       YarnInstaller yarnInstaller = frontEndPluginFactory.getYarnInstaller(getProxyConfig());
       yarnInstaller.setYarnVersion(YARN_VERSION);
       yarnInstaller.install();
+      String yarnCacheDirPath = yarnCacheDir.getAbsolutePath();
+      yarnCommand(frontEndPluginFactory, "config set cache-folder " + yarnCacheDirPath);
 
       configureLogger();
       nodeAndNpmInstalled = true;
@@ -362,7 +367,11 @@ public class HeliumBundleFactory {
     }
 
     // 0. install node, npm (should be called before `downloadPackage`
-    installNodeAndNpm();
+    try {
+      installNodeAndNpm();
+    } catch (TaskRunnerException e) {
+      throw new IOException(e);
+    }
 
     // 1. prepare directories
     if (!heliumLocalRepoDirectory.exists() || !heliumLocalRepoDirectory.isDirectory()) {
@@ -391,7 +400,7 @@ public class HeliumBundleFactory {
     prepareSource(pkg, moduleNameVersion, mainFileName);
 
     // 4. install node and local modules for a bundle
-    copyFrameworkModuleToInstallPath(recopyLocalModule); // should copy local modules first
+    copyFrameworkModulesToInstallPath(recopyLocalModule); // should copy local modules first
     installNodeModules(fpf);
 
     // 5. let's bundle and update cache
@@ -421,7 +430,44 @@ public class HeliumBundleFactory {
     }
   }
 
-  void copyFrameworkModuleToInstallPath(boolean recopy)
+  void copyFrameworkModule(boolean recopy, FileFilter filter,
+                           File src, File dest) throws IOException {
+    if (src != null) {
+      if (recopy && dest.exists()) {
+        FileUtils.deleteDirectory(dest);
+      }
+
+      if (!dest.exists()) {
+        FileUtils.copyDirectory(
+            src,
+            dest,
+            filter);
+      }
+    }
+  }
+
+  void deleteYarnCache() {
+    FilenameFilter filter = new FilenameFilter() {
+      @Override
+      public boolean accept(File dir, String name) {
+        if ((name.startsWith("npm-zeppelin-vis-") ||
+            name.startsWith("npm-zeppelin-tabledata-") ||
+            name.startsWith("npm-zeppelin-spell-")) &&
+            dir.isDirectory()) {
+          return true;
+        }
+
+        return false;
+      }
+    };
+
+    File[] localModuleCaches = yarnCacheDir.listFiles(filter);
+    for (File f : localModuleCaches) {
+      FileUtils.deleteQuietly(f);
+    }
+  }
+
+  void copyFrameworkModulesToInstallPath(boolean recopy)
       throws IOException {
 
     FileFilter npmPackageCopyFilter = new FileFilter() {
@@ -436,56 +482,27 @@ public class HeliumBundleFactory {
       }
     };
 
-    // install tabledata module
     FileUtils.forceMkdir(heliumLocalModuleDirectory);
+    // should delete yarn caches for local modules since they might be updated
+    deleteYarnCache();
 
+    // install tabledata module
     File tabledataModuleInstallPath = new File(heliumLocalModuleDirectory,
         "zeppelin-tabledata");
-    if (tabledataModulePath != null) {
-      if (recopy && tabledataModuleInstallPath.exists()) {
-        FileUtils.deleteDirectory(tabledataModuleInstallPath);
-
-      }
-
-      if (!tabledataModuleInstallPath.exists()) {
-        FileUtils.copyDirectory(
-            tabledataModulePath,
-            tabledataModuleInstallPath,
-            npmPackageCopyFilter);
-      }
-    }
+    copyFrameworkModule(recopy, npmPackageCopyFilter,
+        tabledataModulePath, tabledataModuleInstallPath);
 
     // install visualization module
     File visModuleInstallPath = new File(heliumLocalModuleDirectory,
         "zeppelin-vis");
-    if (visualizationModulePath != null) {
-      if (recopy && visModuleInstallPath.exists()) {
-        FileUtils.deleteDirectory(visModuleInstallPath);
-      }
-
-      if (!visModuleInstallPath.exists()) {
-        FileUtils.copyDirectory(
-            visualizationModulePath,
-            visModuleInstallPath,
-            npmPackageCopyFilter);
-      }
-    }
+    copyFrameworkModule(recopy, npmPackageCopyFilter,
+        visualizationModulePath, visModuleInstallPath);
 
     // install spell module
     File spellModuleInstallPath = new File(heliumLocalModuleDirectory,
         "zeppelin-spell");
-    if (spellModulePath != null) {
-      if (recopy && spellModuleInstallPath.exists()) {
-        FileUtils.deleteDirectory(spellModuleInstallPath);
-      }
-
-      if (!spellModuleInstallPath.exists()) {
-        FileUtils.copyDirectory(
-            spellModulePath,
-            spellModuleInstallPath,
-            npmPackageCopyFilter);
-      }
-    }
+    copyFrameworkModule(recopy, npmPackageCopyFilter,
+        spellModulePath, spellModuleInstallPath);
   }
 
   private WebpackResult getWebpackResultFromOutput(String output) {

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumBundleFactoryTest.java
----------------------------------------------------------------------
diff --git a/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumBundleFactoryTest.java b/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumBundleFactoryTest.java
index d55358f..5feac1d 100644
--- a/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumBundleFactoryTest.java
+++ b/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumBundleFactoryTest.java
@@ -66,7 +66,7 @@ public class HeliumBundleFactoryTest {
         new File(moduleDir, "visualization"),
         new File(moduleDir, "spell"));
     hbf.installNodeAndNpm();
-    hbf.copyFrameworkModuleToInstallPath(true);
+    hbf.copyFrameworkModulesToInstallPath(true);
   }
 
   @After


[2/3] zeppelin git commit: [ZEPPELIN-2217] AdvancedTransformation for Visualization

Posted by mo...@apache.org.
http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-web/src/app/tabledata/advanced-transformation-util.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/tabledata/advanced-transformation-util.js b/zeppelin-web/src/app/tabledata/advanced-transformation-util.js
new file mode 100644
index 0000000..0bcefb6
--- /dev/null
+++ b/zeppelin-web/src/app/tabledata/advanced-transformation-util.js
@@ -0,0 +1,1259 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export function getCurrentChart(config) {
+  return config.chart.current;
+}
+
+export function getCurrentChartTransform(config) {
+  return config.spec.charts[getCurrentChart(config)].transform
+}
+
+export function getCurrentChartAxis(config) {
+  return config.axis[getCurrentChart(config)]
+}
+
+export function getCurrentChartParam(config) {
+  return config.parameter[getCurrentChart(config)]
+}
+
+export function getCurrentChartAxisSpecs(config) {
+  return config.axisSpecs[getCurrentChart(config)]
+}
+
+export function getCurrentChartParamSpecs(config) {
+  return config.paramSpecs[getCurrentChart(config)]
+}
+
+export function useSharedAxis(config, chart) {
+  return config.spec.charts[chart].sharedAxis
+}
+
+export function serializeSharedAxes(config) {
+  const availableCharts = getAvailableChartNames(config.spec.charts)
+  for (let i = 0; i < availableCharts.length; i++) {
+    const chartName = availableCharts[i];
+    if (useSharedAxis(config, chartName)) {
+      /** use reference :) in case of sharedAxis */
+      config.axis[chartName] = config.sharedAxis
+    }
+  }
+}
+
+export const Widget = {
+  INPUT: 'input', /** default */
+  OPTION: 'option',
+  CHECKBOX: 'checkbox',
+  TEXTAREA: 'textarea',
+}
+
+export function isInputWidget(paramSpec) {
+  return (paramSpec && !paramSpec.widget) || (paramSpec && paramSpec.widget === Widget.INPUT);
+}
+
+export function isOptionWidget(paramSpec) {
+  return paramSpec && paramSpec.widget === Widget.OPTION;
+}
+
+export function isCheckboxWidget(paramSpec) {
+  return paramSpec && paramSpec.widget === Widget.CHECKBOX;
+}
+
+export function isTextareaWidget(paramSpec) {
+  return paramSpec && paramSpec.widget === Widget.TEXTAREA;
+}
+
+export const ParameterValueType = {
+  INT: 'int',
+  FLOAT: 'float',
+  BOOLEAN: 'boolean',
+  STRING: 'string',
+  JSON: 'JSON',
+}
+
+export function parseParameter(paramSpecs, param) {
+  /** copy original params */
+  const parsed = JSON.parse(JSON.stringify(param))
+
+  for (let i = 0 ; i < paramSpecs.length; i++) {
+    const paramSpec = paramSpecs[i]
+    const name = paramSpec.name
+
+    if (paramSpec.valueType === ParameterValueType.INT &&
+      typeof parsed[name] !== 'number') {
+
+      try { parsed[name] = parseInt(parsed[name]); }
+      catch (error) { parsed[name] = paramSpec.defaultValue; }
+    }
+    else if (paramSpec.valueType === ParameterValueType.FLOAT &&
+      typeof parsed[name] !== 'number') {
+
+      try { parsed[name] = parseFloat(parsed[name]); }
+      catch (error) { parsed[name] = paramSpec.defaultValue; }
+    }
+    else if (paramSpec.valueType === ParameterValueType.BOOLEAN) {
+      if (parsed[name] === 'false') {
+        parsed[name] = false;
+      } else if (parsed[name] === 'true') {
+        parsed[name] = true;
+      } else if (typeof parsed[name] !== 'boolean') {
+        parsed[name] = paramSpec.defaultValue;
+      }
+    }
+    else if (paramSpec.valueType === ParameterValueType.JSON) {
+      if (parsed[name] !== null && typeof parsed[name] !== 'object') {
+        try { parsed[name] = JSON.parse(parsed[name]); }
+        catch (error) { parsed[name] = paramSpec.defaultValue; }
+      } else if (parsed[name] === null) {
+        parsed[name] = paramSpec.defaultValue;
+      }
+    }
+  }
+
+  return parsed
+}
+
+export const AxisType = {
+  AGGREGATOR: 'aggregator',
+  KEY: 'key',
+  GROUP: 'group',
+}
+
+export function isAggregatorAxis(axisSpec) {
+  return axisSpec && axisSpec.axisType === AxisType.AGGREGATOR
+}
+export function isGroupAxis(axisSpec) {
+  return axisSpec && axisSpec.axisType === AxisType.GROUP
+}
+export function isKeyAxis(axisSpec) {
+  return axisSpec && axisSpec.axisType === AxisType.KEY
+}
+export function isSingleDimensionAxis(axisSpec) {
+  return axisSpec && axisSpec.dimension === 'single'
+}
+
+/**
+ * before: { name: { ... } }
+ * after: [ { name, ... } ]
+ *
+ * add the `name` field while converting to array to easily manipulate
+ */
+export function getSpecs(specObject) {
+  const specs = [];
+  for (let name in specObject) {
+    const singleSpec = specObject[name];
+    if (!singleSpec) { continue }
+    singleSpec.name = name;
+    specs.push(singleSpec);
+  }
+
+  return specs
+}
+
+export function getAvailableChartNames(charts) {
+  const available = []
+  for (var name in charts) {
+    available.push(name)
+  }
+
+  return available
+}
+
+export function applyMaxAxisCount(config, axisSpec) {
+  if (isSingleDimensionAxis(axisSpec) || typeof axisSpec.maxAxisCount === 'undefined') {
+    return;
+  }
+
+  const columns = getCurrentChartAxis(config)[axisSpec.name]
+  if (columns.length <= axisSpec.maxAxisCount) { return; }
+
+  const sliced = columns.slice(1)
+  getCurrentChartAxis(config)[axisSpec.name] = sliced
+}
+
+export function removeDuplicatedColumnsInMultiDimensionAxis(config, axisSpec) {
+  if (isSingleDimensionAxis(axisSpec)) { return config }
+
+  const columns = getCurrentChartAxis(config)[axisSpec.name]
+  const uniqObject = columns.reduce((acc, col) => {
+    if (!acc[`${col.name}(${col.aggr})`]) { acc[`${col.name}(${col.aggr})`] = col }
+    return acc
+  }, {})
+
+  const filtered = []
+  for (let name in uniqObject) {
+    const col = uniqObject[name]
+    filtered.push(col)
+  }
+
+  getCurrentChartAxis(config)[axisSpec.name] = filtered
+  return config
+}
+
+export function clearAxisConfig(config) {
+  delete config.axis /** Object: persisted axis for each chart */
+  delete config.sharedAxis
+}
+
+export function initAxisConfig(config) {
+  if (!config.axis) { config.axis = {} }
+  if (!config.sharedAxis) { config.sharedAxis = {} }
+
+  const spec = config.spec
+  const availableCharts = getAvailableChartNames(spec.charts)
+
+  if (!config.axisSpecs) { config.axisSpecs = {}; }
+  for (let i = 0; i < availableCharts.length; i++) {
+    const chartName = availableCharts[i];
+
+    if (!config.axis[chartName]) {
+      config.axis[chartName] = {};
+    }
+    const axisSpecs = getSpecs(spec.charts[chartName].axis)
+    if (!config.axisSpecs[chartName]) {
+      config.axisSpecs[chartName] = axisSpecs;
+    }
+
+    /** initialize multi-dimension axes */
+    for (let i = 0; i < axisSpecs.length; i++) {
+      const axisSpec = axisSpecs[i]
+      if (isSingleDimensionAxis(axisSpec)) {
+        continue;
+      }
+
+      /** intentionally nested if-stmt is used because order of conditions matter here */
+      if (!useSharedAxis(config, chartName)) {
+        if (!Array.isArray(config.axis[chartName][axisSpec.name])) {
+          config.axis[chartName][axisSpec.name] = []
+        }
+      } else if (useSharedAxis(config, chartName)) {
+        /**
+         * initialize multiple times even if shared axis because it's not that expensive, assuming that
+         * all charts using shared axis have the same axis specs
+         */
+        if (!Array.isArray(config.sharedAxis[axisSpec.name])) {
+          config.sharedAxis[axisSpec.name] = []
+        }
+      }
+    }
+  }
+
+  /** this function should be called after initializing */
+  serializeSharedAxes(config)
+}
+
+export function resetAxisConfig(config) {
+  clearAxisConfig(config)
+  initAxisConfig(config)
+}
+
+export function clearParameterConfig(config) {
+  delete config.parameter  /** Object: persisted parameter for each chart */
+}
+
+export function initParameterConfig(config) {
+  if (!config.parameter) { config.parameter = {} }
+
+  const spec = config.spec
+  const availableCharts = getAvailableChartNames(spec.charts)
+
+  if (!config.paramSpecs) { config.paramSpecs = {}; }
+  for (let i = 0; i < availableCharts.length; i++) {
+    const chartName = availableCharts[i];
+
+    if (!config.parameter[chartName]) { config.parameter[chartName] = {}; }
+    const paramSpecs = getSpecs(spec.charts[chartName].parameter)
+    if (!config.paramSpecs[chartName]) { config.paramSpecs[chartName] = paramSpecs; }
+
+    for (let i = 0; i < paramSpecs.length; i++) {
+      const paramSpec = paramSpecs[i];
+      if (!config.parameter[chartName][paramSpec.name]) {
+        config.parameter[chartName][paramSpec.name] = paramSpec.defaultValue;
+      }
+    }
+  }
+}
+
+export function resetParameterConfig(config) {
+  clearParameterConfig(config)
+  initParameterConfig(config)
+}
+
+export function getSpecVersion(availableCharts, spec) {
+  const axisHash = {}
+  const paramHash = {}
+
+  for (let i = 0; i < availableCharts.length; i++) {
+    const chartName = availableCharts[i];
+    const axisSpecs = getSpecs(spec.charts[chartName].axis)
+    axisHash[chartName] = axisSpecs
+
+    const paramSpecs = getSpecs(spec.charts[chartName].parameter)
+    paramHash[chartName] = paramSpecs
+  }
+
+  return { axisVersion: JSON.stringify(axisHash), paramVersion: JSON.stringify(paramHash), }
+}
+
+export function initializeConfig(config, spec) {
+  config.chartChanged = true
+  config.parameterChanged = false
+
+  let updated = false
+
+  const availableCharts = getAvailableChartNames(spec.charts)
+  const { axisVersion, paramVersion, } = getSpecVersion(availableCharts, spec)
+
+  if (!config.spec || !config.spec.version ||
+    !config.spec.version.axis ||
+    config.spec.version.axis !== axisVersion) {
+
+    spec.initialized = true
+    updated = true
+
+    delete config.chart      /** Object: contains current, available chart */
+    config.panel = { columnPanelOpened: true, parameterPanelOpened: false, }
+
+    clearAxisConfig(config)
+    delete config.axisSpecs  /** Object: persisted axisSpecs for each chart */
+  }
+
+  if (!config.spec || !config.spec.version ||
+    !config.spec.version.parameter ||
+    config.spec.version.parameter !== paramVersion) {
+
+    updated = true
+
+    clearParameterConfig(config)
+    delete config.paramSpecs /** Object: persisted paramSpecs for each chart */
+  }
+
+  if (!spec.version) { spec.version = {} }
+  spec.version.axis = axisVersion
+  spec.version.parameter = paramVersion
+
+  if (!config.spec || updated) { config.spec = spec; }
+
+  if (!config.chart) {
+    config.chart = {};
+    config.chart.current = availableCharts[0];
+    config.chart.available = availableCharts;
+  }
+
+  /** initialize config.axis, config.axisSpecs for each chart */
+  initAxisConfig(config)
+
+  /** initialize config.parameter for each chart */
+  initParameterConfig(config)
+  return config
+}
+
+export function getColumnsForMultipleAxes(axisType, axisSpecs, axis) {
+  const axisNames = []
+  let column = {}
+
+  for(let i = 0; i < axisSpecs.length; i++) {
+    const axisSpec = axisSpecs[i];
+
+    if (axisType === AxisType.KEY && isKeyAxis(axisSpec)) {
+      axisNames.push(axisSpec.name)
+    } else if (axisType === AxisType.GROUP && isGroupAxis(axisSpec)) {
+      axisNames.push(axisSpec.name)
+    } else if (axisType.AGGREGATOR && isAggregatorAxis(axisSpec)) {
+      axisNames.push(axisSpec.name)
+    }
+  }
+
+  for(let axisName of axisNames) {
+    const columns = axis[axisName];
+    if (typeof axis[axisName] === 'undefined') { continue }
+    if (!column[axisName]) { column[axisName] = [] }
+    column[axisName] = column[axisName].concat(columns)
+  }
+
+  return column
+}
+
+export function getColumnsFromAxis(axisSpecs, axis) {
+  const keyAxisNames = [];
+  const groupAxisNames = [];
+  const aggrAxisNames = [];
+
+  for(let i = 0; i < axisSpecs.length; i++) {
+    const axisSpec = axisSpecs[i];
+
+    if (isKeyAxis(axisSpec)) { keyAxisNames.push(axisSpec.name); }
+    else if (isGroupAxis(axisSpec)) { groupAxisNames.push(axisSpec.name); }
+    else if (isAggregatorAxis(axisSpec)) { aggrAxisNames.push(axisSpec.name); }
+  }
+
+  let keyColumns = [];
+  let groupColumns = [];
+  let aggregatorColumns = [];
+  let customColumn = {};
+
+  for(let axisName in axis) {
+    const columns = axis[axisName];
+    if (keyAxisNames.includes(axisName)) {
+      keyColumns = keyColumns.concat(columns);
+    } else if (groupAxisNames.includes(axisName)) {
+      groupColumns = groupColumns.concat(columns);
+    } else if (aggrAxisNames.includes(axisName)) {
+      aggregatorColumns = aggregatorColumns.concat(columns);
+    } else {
+      const axisType = axisSpecs.filter(s => s.name === axisName)[0].axisType
+      if (!customColumn[axisType]) { customColumn[axisType] = []; }
+      customColumn[axisType] = customColumn[axisType].concat(columns);
+    }
+  }
+
+  return {
+    key: keyColumns,
+    group: groupColumns,
+    aggregator: aggregatorColumns,
+    custom: customColumn,
+  }
+}
+
+export const Aggregator = {
+  SUM: 'sum',
+  COUNT: 'count',
+  AVG: 'avg',
+  MIN: 'min',
+  MAX: 'max',
+}
+
+const TransformMethod = {
+  /**
+   * `raw` is designed for users who want to get raw (original) rows.
+   */
+  RAW: 'raw',
+  /**
+   * `object` is * designed for serial(line, area) charts.
+   */
+  OBJECT: 'object',
+  /**
+   * `array` is designed for column, pie charts which have categorical `key` values.
+   * But some libraries may require `OBJECT` transform method even if pie, column charts.
+   *
+   * `row.value` will be filled for `keyNames`.
+   * In other words, if you have `keyNames` which is length 4,
+   * every `row.value`'s length will be 4 too.
+   * (DO NOT use this transform method for serial (numerical) x axis charts which have so-oo many keys)
+   */
+  ARRAY: 'array',
+  ARRAY_2_KEY: 'array:2-key',
+  DRILL_DOWN: 'drill-down',
+}
+
+/** return function for lazy computation */
+export function getTransformer(conf, rows, axisSpecs, axis) {
+  let transformer = () => {}
+
+  const transformSpec = getCurrentChartTransform(conf)
+  if (!transformSpec) { return transformer }
+
+  const method = transformSpec.method
+
+  const columns = getColumnsFromAxis(axisSpecs, axis);
+  const keyColumns = columns.key;
+  const groupColumns = columns.group;
+  const aggregatorColumns = columns.aggregator;
+  const customColumns = columns.custom
+
+  let column = {
+    key: keyColumns, group: groupColumns, aggregator: aggregatorColumns, custom: customColumns,
+  }
+
+  if (method === TransformMethod.RAW) {
+    transformer = () => { return rows; }
+  } else if (method === TransformMethod.OBJECT) {
+    transformer = () => {
+      const { cube, schema, keyColumnName,  keyNames, groupNameSet, selectorNameWithIndex, } =
+        getKGACube(rows, keyColumns, groupColumns, aggregatorColumns)
+
+      const {
+        transformed, groupNames, sortedSelectors
+      } = getObjectRowsFromKGACube(cube, schema, aggregatorColumns,
+        keyColumnName, keyNames, groupNameSet, selectorNameWithIndex)
+
+      return {
+        rows: transformed, keyColumnName,
+        keyNames,
+        groupNames: groupNames,
+        selectors: sortedSelectors,
+      }
+    }
+  } else if (method === TransformMethod.ARRAY) {
+    transformer = () => {
+      const { cube, schema, keyColumnName, keyNames, groupNameSet, selectorNameWithIndex, } =
+        getKGACube(rows, keyColumns, groupColumns, aggregatorColumns)
+
+      const {
+        transformed, groupNames, sortedSelectors,
+      } = getArrayRowsFromKGACube(cube, schema, aggregatorColumns,
+        keyColumnName, keyNames, groupNameSet, selectorNameWithIndex)
+
+      return {
+        rows: transformed, keyColumnName,
+        keyNames,
+        groupNames: groupNames,
+        selectors: sortedSelectors,
+      }
+    }
+  } else if (method === TransformMethod.ARRAY_2_KEY) {
+    const keyAxisColumn = getColumnsForMultipleAxes(AxisType.KEY, axisSpecs, axis)
+    column.key = keyAxisColumn
+
+    let key1Columns = []
+    let key2Columns = []
+
+    // since ARRAY_2_KEY :)
+    let i = 0
+    for (let axisName in keyAxisColumn) {
+      if (i === 2) { break }
+
+      if (i === 0) { key1Columns = keyAxisColumn[axisName] }
+      else if (i === 1) { key2Columns  = keyAxisColumn[axisName] }
+      i++
+    }
+
+    const { cube, schema,
+      key1ColumnName, key1Names, key2ColumnName, key2Names,
+      groupNameSet, selectorNameWithIndex,
+    } = getKKGACube(rows, key1Columns, key2Columns, groupColumns, aggregatorColumns)
+
+    const {
+      transformed, groupNames, sortedSelectors,
+      key1NameWithIndex, key2NameWithIndex,
+    } = getArrayRowsFromKKGACube(cube, schema, aggregatorColumns,
+      key1Names, key2Names, groupNameSet, selectorNameWithIndex)
+
+    transformer = () => {
+      return {
+        rows: transformed,
+        key1Names: key1Names,
+        key1ColumnName: key1ColumnName,
+        key1NameWithIndex: key1NameWithIndex,
+        key2Names: key2Names,
+        key2ColumnName: key2ColumnName,
+        key2NameWithIndex: key2NameWithIndex,
+        groupNames: groupNames,
+        selectors: sortedSelectors,
+      }
+    }
+  }
+  else if (method === TransformMethod.DRILL_DOWN) {
+    transformer = () => {
+      const { cube, schema, keyColumnName, keyNames, groupNameSet, selectorNameWithIndex, } =
+        getKAGCube(rows, keyColumns, groupColumns, aggregatorColumns)
+
+      const {
+        transformed, groupNames, sortedSelectors,
+      } = getDrilldownRowsFromKAGCube(cube, schema, aggregatorColumns,
+        keyColumnName, keyNames, groupNameSet, selectorNameWithIndex)
+
+      return {
+        rows: transformed, keyColumnName, keyNames,
+        groupNames: groupNames,
+        selectors: sortedSelectors,
+      }
+    }
+  }
+
+  return { transformer: transformer, column: column, }
+}
+
+const AggregatorFunctions = {
+  sum: function(a, b) {
+    const varA = (a !== undefined) ? (isNaN(a) ? 1 : parseFloat(a)) : 0;
+    const varB = (b !== undefined) ? (isNaN(b) ? 1 : parseFloat(b)) : 0;
+    return varA + varB;
+  },
+  count: function(a, b) {
+    const varA = (a !== undefined) ? parseInt(a) : 0;
+    const varB = (b !== undefined) ? 1 : 0;
+    return varA + varB;
+  },
+  min: function(a, b) {
+    const varA = (a !== undefined) ? (isNaN(a) ? 1 : parseFloat(a)) : 0;
+    const varB = (b !== undefined) ? (isNaN(b) ? 1 : parseFloat(b)) : 0;
+    return Math.min(varA,varB);
+  },
+  max: function(a, b) {
+    const varA = (a !== undefined) ? (isNaN(a) ? 1 : parseFloat(a)) : 0;
+    const varB = (b !== undefined) ? (isNaN(b) ? 1 : parseFloat(b)) : 0;
+    return Math.max(varA,varB);
+  },
+  avg: function(a, b, c) {
+    const varA = (a !== undefined) ? (isNaN(a) ? 1 : parseFloat(a)) : 0;
+    const varB = (b !== undefined) ? (isNaN(b) ? 1 : parseFloat(b)) : 0;
+    return varA + varB;
+  }
+};
+
+const AggregatorFunctionDiv = {
+  sum: false,
+  min: false,
+  max: false,
+  count: false,
+  avg: true
+};
+
+/** nested cube `(key) -> (group) -> aggregator` */
+export function getKGACube(rows, keyColumns, groupColumns, aggrColumns) {
+  const schema = {
+    key: keyColumns.length !== 0,
+    group: groupColumns.length !== 0,
+    aggregator: aggrColumns.length !== 0,
+  };
+
+  let cube = {}
+  const entry = {}
+
+  const keyColumnName = keyColumns.map(c => c.name).join('.')
+  const groupNameSet = new Set()
+  const keyNameSet = new Set()
+  const selectorNameWithIndex = {} /** { selectorName: index } */
+  let indexCounter = 0
+
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+    let e = entry;
+    let c = cube;
+
+    // key: add to entry
+    let mergedKeyName = undefined
+    if (schema.key) {
+      mergedKeyName = keyColumns.map(c => row[c.index]).join('.')
+      if (!e[mergedKeyName]) { e[mergedKeyName] = { children: {}, } }
+      e = e[mergedKeyName].children
+      // key: add to row
+      if (!c[mergedKeyName]) { c[mergedKeyName] = {} }
+      c = c[mergedKeyName]
+
+      keyNameSet.add(mergedKeyName)
+    }
+
+    let mergedGroupName = undefined
+    if (schema.group) {
+      mergedGroupName = groupColumns.map(c => row[c.index]).join('.')
+
+      // add group to entry
+      if (!e[mergedGroupName]) { e[mergedGroupName] = { children: {}, } }
+      e = e[mergedGroupName].children
+      // add group to row
+      if (!c[mergedGroupName]) { c[mergedGroupName] = {} }
+      c = c[mergedGroupName]
+      groupNameSet.add(mergedGroupName)
+    }
+
+    for (let a = 0; a < aggrColumns.length; a++) {
+      const aggrColumn = aggrColumns[a]
+      const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+
+      // update groupNameSet
+      if (!mergedGroupName) {
+        groupNameSet.add(aggrName) /** aggr column name will be used as group name if group is empty */
+      }
+
+      // update selectorNameWithIndex
+      const selector = getSelectorName(mergedGroupName, aggrColumns.length, aggrName)
+      if (typeof selectorNameWithIndex[selector] === 'undefined' /** value might be 0 */) {
+        selectorNameWithIndex[selector] = indexCounter
+        indexCounter = indexCounter + 1
+      }
+
+      // add aggregator to entry
+      if (!e[aggrName]) {
+        e[aggrName] = { type: 'aggregator', order: aggrColumn, index: aggrColumn.index, }
+      }
+
+      // add aggregatorName to row
+      if (!c[aggrName]) {
+        c[aggrName] = {
+          aggr: aggrColumn.aggr,
+          value: (aggrColumn.aggr !== 'count') ? row[aggrColumn.index] : 1,
+          count: 1,
+        }
+      } else {
+        const value = AggregatorFunctions[aggrColumn.aggr](
+          c[aggrName].value, row[aggrColumn.index], c[aggrName].count + 1)
+        const count = (AggregatorFunctionDiv[aggrColumn.aggr]) ?
+          c[aggrName].count + 1 : c[aggrName].count
+
+        c[aggrName].value = value
+        c[aggrName].count = count
+      }
+
+    } /** end loop for aggrColumns */
+  }
+
+  let keyNames = null
+  if (!schema.key) {
+    const mergedGroupColumnName = groupColumns.map(c => c.name).join('.')
+    cube = { [mergedGroupColumnName]: cube, }
+    keyNames = [ mergedGroupColumnName, ]
+  } else {
+    keyNames = Object.keys(cube).sort() /** keys should be sorted */
+  }
+
+  return {
+    cube: cube,
+    schema: schema,
+    keyColumnName: keyColumnName,
+    keyNames: keyNames,
+    groupNameSet: groupNameSet,
+    selectorNameWithIndex: selectorNameWithIndex,
+  }
+}
+
+/** nested cube `(key) -> aggregator -> (group)` for drill-down support */
+export function getKAGCube(rows, keyColumns, groupColumns, aggrColumns) {
+  const schema = {
+    key: keyColumns.length !== 0,
+    group: groupColumns.length !== 0,
+    aggregator: aggrColumns.length !== 0,
+  };
+
+  let cube = {}
+
+  const keyColumnName = keyColumns.map(c => c.name).join('.')
+  const groupNameSet = new Set()
+  const keyNameSet = new Set()
+  const selectorNameWithIndex = {} /** { selectorName: index } */
+  let indexCounter = 0
+
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+    let c = cube;
+
+    // key: add to entry
+    let mergedKeyName = undefined
+    if (schema.key) {
+      mergedKeyName = keyColumns.map(c => row[c.index]).join('.')
+      // key: add to row
+      if (!c[mergedKeyName]) { c[mergedKeyName] = {} }
+      c = c[mergedKeyName]
+
+      keyNameSet.add(mergedKeyName)
+    }
+
+    let mergedGroupName = undefined
+    if (schema.group) {
+      mergedGroupName = groupColumns.map(c => row[c.index]).join('.')
+      groupNameSet.add(mergedGroupName)
+    }
+
+    for (let a = 0; a < aggrColumns.length; a++) {
+      const aggrColumn = aggrColumns[a]
+      const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+
+      // update groupNameSet
+      if (!mergedGroupName) {
+        groupNameSet.add(aggrName) /** aggr column name will be used as group name if group is empty */
+      }
+
+      // update selectorNameWithIndex
+      const selector = getSelectorName(mergedKeyName, aggrColumns.length, aggrName)
+      if (typeof selectorNameWithIndex[selector] === 'undefined' /** value might be 0 */) {
+        selectorNameWithIndex[selector] = indexCounter
+        indexCounter = indexCounter + 1
+      }
+
+      // add aggregatorName to row
+      if (!c[aggrName]) {
+        const value = (aggrColumn.aggr !== 'count') ? row[aggrColumn.index] : 1
+        const count = 1
+
+        c[aggrName] = { aggr: aggrColumn.aggr, value: value, count: count, children: {}, }
+      } else {
+        const value = AggregatorFunctions[aggrColumn.aggr](
+          c[aggrName].value, row[aggrColumn.index], c[aggrName].count + 1)
+        const count = (AggregatorFunctionDiv[aggrColumn.aggr]) ?
+          c[aggrName].count + 1 : c[aggrName].count
+
+        c[aggrName].value = value
+        c[aggrName].count = count
+      }
+
+      // add aggregated group (for drill-down) to row iff group is enabled
+      if (mergedGroupName) {
+        if (!c[aggrName].children[mergedGroupName]) {
+          const value = (aggrColumn.aggr !== 'count') ? row[aggrColumn.index] : 1
+          const count = 1
+
+          c[aggrName].children[mergedGroupName] = { value: value, count: count, }
+        } else {
+          const drillDownedValue = c[aggrName].children[mergedGroupName].value
+          const drillDownedCount = c[aggrName].children[mergedGroupName].count
+          const value = AggregatorFunctions[aggrColumn.aggr](
+            drillDownedValue, row[aggrColumn.index], drillDownedCount + 1)
+          const count = (AggregatorFunctionDiv[aggrColumn.aggr]) ?
+            drillDownedCount + 1 : drillDownedCount
+
+          c[aggrName].children[mergedGroupName].value = value
+          c[aggrName].children[mergedGroupName].count = count
+        }
+
+      }
+
+    } /** end loop for aggrColumns */
+  }
+
+  let keyNames = null
+  if (!schema.key) {
+    const mergedGroupColumnName = groupColumns.map(c => c.name).join('.')
+    cube = { [mergedGroupColumnName]: cube, }
+    keyNames = [ mergedGroupColumnName, ]
+  } else {
+    keyNames = Object.keys(cube).sort() /** keys should be sorted */
+  }
+
+  return {
+    cube: cube,
+    schema: schema,
+    keyColumnName: keyColumnName,
+    keyNames: keyNames,
+    groupNameSet: groupNameSet,
+    selectorNameWithIndex: selectorNameWithIndex,
+  }
+}
+/** nested cube `(key1) -> (key2) -> (group) -> aggregator` */
+export function getKKGACube(rows, key1Columns, key2Columns, groupColumns, aggrColumns) {
+  const schema = {
+    key1: key1Columns.length !== 0,
+    key2: key2Columns.length !== 0,
+    group: groupColumns.length !== 0,
+    aggregator: aggrColumns.length !== 0,
+  };
+
+  let cube = {}
+  const entry = {}
+
+  const key1ColumnName = key1Columns.map(c => c.name).join('.')
+  const key1NameSet = {}
+  const key2ColumnName = key2Columns.map(c => c.name).join('.')
+  const key2NameSet = {}
+  const groupNameSet = new Set()
+  const selectorNameWithIndex = {} /** { selectorName: index } */
+  let indexCounter = 0
+
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+    let e = entry;
+    let c = cube;
+
+    // key1: add to entry
+    let mergedKey1Name = undefined
+    if (schema.key1) {
+      mergedKey1Name = key1Columns.map(c => row[c.index]).join('.')
+      if (!e[mergedKey1Name]) { e[mergedKey1Name] = { children: {}, } }
+      e = e[mergedKey1Name].children
+      // key1: add to row
+      if (!c[mergedKey1Name]) { c[mergedKey1Name] = {} }
+      c = c[mergedKey1Name]
+
+      if (!key1NameSet[mergedKey1Name]) { key1NameSet[mergedKey1Name] = true }
+    }
+
+    // key2: add to entry
+    let mergedKey2Name = undefined
+    if (schema.key2) {
+      mergedKey2Name = key2Columns.map(c => row[c.index]).join('.')
+      if (!e[mergedKey2Name]) { e[mergedKey2Name] = { children: {}, } }
+      e = e[mergedKey2Name].children
+      // key2: add to row
+      if (!c[mergedKey2Name]) { c[mergedKey2Name] = {} }
+      c = c[mergedKey2Name]
+
+      if (!key2NameSet[mergedKey2Name]) { key2NameSet[mergedKey2Name] = true }
+    }
+
+    let mergedGroupName = undefined
+    if (schema.group) {
+      mergedGroupName = groupColumns.map(c => row[c.index]).join('.')
+
+      // add group to entry
+      if (!e[mergedGroupName]) { e[mergedGroupName] = { children: {}, } }
+      e = e[mergedGroupName].children
+      // add group to row
+      if (!c[mergedGroupName]) { c[mergedGroupName] = {} }
+      c = c[mergedGroupName]
+      groupNameSet.add(mergedGroupName)
+    }
+
+    for (let a = 0; a < aggrColumns.length; a++) {
+      const aggrColumn = aggrColumns[a]
+      const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+
+      // update groupNameSet
+      if (!mergedGroupName) {
+        groupNameSet.add(aggrName) /** aggr column name will be used as group name if group is empty */
+      }
+
+      // update selectorNameWithIndex
+      const selector = getSelectorName(mergedGroupName, aggrColumns.length, aggrName)
+      if (typeof selectorNameWithIndex[selector] === 'undefined' /** value might be 0 */) {
+        selectorNameWithIndex[selector] = indexCounter
+        indexCounter = indexCounter + 1
+      }
+
+      // add aggregator to entry
+      if (!e[aggrName]) {
+        e[aggrName] = { type: 'aggregator', order: aggrColumn, index: aggrColumn.index, }
+      }
+
+      // add aggregatorName to row
+      if (!c[aggrName]) {
+        c[aggrName] = {
+          aggr: aggrColumn.aggr,
+          value: (aggrColumn.aggr !== 'count') ? row[aggrColumn.index] : 1,
+          count: 1,
+        }
+      } else {
+        const value = AggregatorFunctions[aggrColumn.aggr](
+          c[aggrName].value, row[aggrColumn.index], c[aggrName].count + 1)
+        const count = (AggregatorFunctionDiv[aggrColumn.aggr]) ?
+          c[aggrName].count + 1 : c[aggrName].count
+
+        c[aggrName].value = value
+        c[aggrName].count = count
+      }
+
+    } /** end loop for aggrColumns */
+  }
+
+  let key1Names = Object.keys(key1NameSet).sort() /** keys should be sorted */
+  let key2Names = Object.keys(key2NameSet).sort() /** keys should be sorted */
+
+  return {
+    cube: cube,
+    schema: schema,
+    key1ColumnName: key1ColumnName,
+    key1Names: key1Names,
+    key2ColumnName: key2ColumnName,
+    key2Names: key2Names,
+    groupNameSet: groupNameSet,
+    selectorNameWithIndex: selectorNameWithIndex,
+  }
+}
+
+export function getSelectorName(mergedGroupName, aggrColumnLength, aggrColumnName) {
+  if (!mergedGroupName) {
+    return aggrColumnName
+  } else {
+    return (aggrColumnLength > 1) ?
+      `${mergedGroupName} / ${aggrColumnName}` : mergedGroupName
+  }
+}
+
+export function getCubeValue(obj, aggregator, aggrColumnName) {
+  let value = null /** default is null */
+  try {
+    /** if AVG or COUNT, calculate it now, previously we can't because we were doing accumulation */
+    if (aggregator === Aggregator.AVG) {
+      value = obj[aggrColumnName].value / obj[aggrColumnName].count
+    } else if (aggregator === Aggregator.COUNT) {
+      value = obj[aggrColumnName].value
+    } else {
+      value = obj[aggrColumnName].value
+    }
+
+    if (typeof value === 'undefined') { value = null }
+  } catch (error) { /** iognore */ }
+
+  return value
+}
+
+export function getNameWithIndex(names) {
+  const nameWithIndex = {}
+
+  for (let i = 0; i < names.length; i++) {
+    const name = names[i]
+    nameWithIndex[name] = i
+  }
+
+  return nameWithIndex
+}
+
+export function getArrayRowsFromKKGACube(cube, schema, aggregatorColumns,
+                                         key1Names, key2Names, groupNameSet, selectorNameWithIndex) {
+
+  const sortedSelectors = Object.keys(selectorNameWithIndex).sort()
+  const sortedSelectorNameWithIndex = getNameWithIndex(sortedSelectors)
+
+  const selectorRows = new Array(sortedSelectors.length)
+  const key1NameWithIndex = getNameWithIndex(key1Names)
+  const key2NameWithIndex = getNameWithIndex(key2Names)
+
+  fillSelectorRows(schema, cube, selectorRows,
+    aggregatorColumns, sortedSelectorNameWithIndex,
+    key1Names, key2Names, key1NameWithIndex, key2NameWithIndex)
+
+  return {
+    key1NameWithIndex: key1NameWithIndex,
+    key2NameWithIndex: key2NameWithIndex,
+    transformed: selectorRows,
+    groupNames: Array.from(groupNameSet).sort(),
+    sortedSelectors: sortedSelectors,
+  }
+}
+
+/** truly mutable style func. will return nothing */
+export function fillSelectorRows(schema, cube, selectorRows,
+                                 aggrColumns, selectorNameWithIndex,
+                                 key1Names, key2Names) {
+
+  function fill(grouped, mergedGroupName, key1Name, key2Name) {
+    // should iterate aggrColumns in the most nested loop to utilize memory locality
+    for (let aggrColumn of aggrColumns) {
+      const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+      const value = getCubeValue(grouped, aggrColumn.aggr, aggrName)
+      const selector = getSelectorName(mergedGroupName, aggrColumns.length, aggrName)
+      const selectorIndex = selectorNameWithIndex[selector]
+
+      if (typeof selectorRows[selectorIndex] === 'undefined') {
+        selectorRows[selectorIndex] = { selector: selector, value: [], }
+      }
+
+      const row = { aggregated: value, }
+
+      if (typeof key1Name !== 'undefined') { row.key1 = key1Name }
+      if (typeof key2Name !== 'undefined') { row.key2 = key2Name }
+
+      selectorRows[selectorIndex].value.push(row)
+    }
+  }
+
+  function iterateGroupNames(keyed, key1Name, key2Name) {
+    if (!schema.group) {
+      fill(keyed, undefined, key1Name, key2Name)
+    } else {
+      // assuming sparse distribution (usual case)
+      // otherwise we need to iterate using `groupNameSet`
+      const availableGroupNames = Object.keys(keyed)
+
+      for (let groupName of availableGroupNames) {
+        const grouped = keyed[groupName]
+        fill(grouped, groupName, key1Name, key2Name)
+      }
+    }
+  }
+
+  if (schema.key1 && schema.key2) {
+    for (let key1Name of key1Names) {
+      const key1ed = cube[key1Name]
+
+      // assuming sparse distribution (usual case)
+      // otherwise we need to iterate using `key2Names`
+      const availableKey2Names = Object.keys(key1ed)
+
+      for (let key2Name of availableKey2Names) {
+        const keyed = key1ed[key2Name]
+        iterateGroupNames(keyed, key1Name, key2Name)
+      }
+    }
+  } else if (schema.key1 && !schema.key2) {
+    for (let key1Name of key1Names) {
+      const keyed = cube[key1Name]
+      iterateGroupNames(keyed, key1Name, undefined)
+    }
+  } else if (!schema.key1 && schema.key2) {
+    for (let key2Name of key2Names) {
+      const keyed = cube[key2Name]
+      iterateGroupNames(keyed, undefined, key2Name)
+    }
+  } else {
+    iterateGroupNames(cube, undefined, undefined)
+  }
+}
+
+export function getArrayRowsFromKGACube(cube, schema, aggregatorColumns,
+                                        keyColumnName, keyNames, groupNameSet,
+                                        selectorNameWithIndex) {
+
+  const sortedSelectors = Object.keys(selectorNameWithIndex).sort()
+  const sortedSelectorNameWithIndex = getNameWithIndex(sortedSelectors)
+
+  const keyArrowRows = new Array(sortedSelectors.length)
+  const keyNameWithIndex = getNameWithIndex(keyNames)
+
+  for(let i = 0; i < keyNames.length; i++) {
+    const key = keyNames[i]
+
+    const obj = cube[key]
+    fillArrayRow(schema, aggregatorColumns, obj,
+      groupNameSet, sortedSelectorNameWithIndex,
+      key, keyNames, keyArrowRows, keyNameWithIndex)
+  }
+
+  return {
+    transformed: keyArrowRows,
+    groupNames: Array.from(groupNameSet).sort(),
+    sortedSelectors: sortedSelectors,
+  }
+}
+
+/** truly mutable style func. will return nothing, just modify `keyArrayRows` */
+export function fillArrayRow(schema, aggrColumns, obj,
+                             groupNameSet, selectorNameWithIndex,
+                             keyName, keyNames, keyArrayRows, keyNameWithIndex) {
+
+  function fill(target, mergedGroupName, aggr, aggrName) {
+    const value = getCubeValue(target, aggr, aggrName)
+    const selector = getSelectorName(mergedGroupName, aggrColumns.length, aggrName)
+    const selectorIndex = selectorNameWithIndex[selector]
+    const keyIndex = keyNameWithIndex[keyName]
+
+    if (typeof keyArrayRows[selectorIndex] === 'undefined') {
+      keyArrayRows[selectorIndex] = {
+        selector: selector, value: new Array(keyNames.length)
+      }
+    }
+    keyArrayRows[selectorIndex].value[keyIndex] = value
+  }
+
+  /** when group is empty */
+  if (!schema.group) {
+    for(let i = 0; i < aggrColumns.length; i++) {
+      const aggrColumn = aggrColumns[i]
+      const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+      fill(obj, undefined, aggrColumn.aggr, aggrName)
+    }
+  } else {
+    for(let i = 0; i < aggrColumns.length; i++) {
+      const aggrColumn = aggrColumns[i]
+      const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+
+      for (let groupName of groupNameSet) {
+        const grouped = obj[groupName]
+        fill(grouped, groupName, aggrColumn.aggr, aggrName)
+      }
+    }
+  }
+}
+
+export function getObjectRowsFromKGACube(cube, schema, aggregatorColumns,
+                                         keyColumnName, keyNames, groupNameSet,
+                                         selectorNameWithIndex) {
+
+  const rows = keyNames.reduce((acc, key) => {
+    const obj = cube[key]
+    const row = getObjectRow(schema, aggregatorColumns, obj, groupNameSet)
+
+    if (schema.key) { row[keyColumnName] = key }
+    acc.push(row)
+
+    return acc
+  }, [])
+
+  return {
+    transformed: rows,
+    sortedSelectors: Object.keys(selectorNameWithIndex).sort(),
+    groupNames: Array.from(groupNameSet).sort(),
+  }
+}
+
+export function getObjectRow(schema, aggrColumns, obj, groupNameSet) {
+  const row = {}
+
+  function fill(row, target, mergedGroupName, aggr, aggrName) {
+    const value = getCubeValue(target, aggr, aggrName)
+    const selector = getSelectorName(mergedGroupName, aggrColumns.length, aggrName)
+    row[selector] = value
+  }
+
+  /** when group is empty */
+  if (!schema.group) {
+    for(let i = 0; i < aggrColumns.length; i++) {
+      const aggrColumn = aggrColumns[i]
+      const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+
+      fill(row, obj, undefined, aggrColumn.aggr, aggrName)
+    }
+
+    return row
+  }
+
+  /** when group is specified */
+  for(let i = 0; i < aggrColumns.length; i++) {
+    const aggrColumn = aggrColumns[i]
+    const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+
+    for (let groupName of groupNameSet) {
+      const grouped = obj[groupName]
+
+      if (grouped) {
+        fill(row, grouped, groupName, aggrColumn.aggr, aggrName)
+      }
+    }
+  }
+
+  return row
+}
+
+export function getDrilldownRowsFromKAGCube(cube, schema, aggregatorColumns,
+                                            keyColumnName, keyNames, groupNameSet, selectorNameWithIndex) {
+
+  const sortedSelectors = Object.keys(selectorNameWithIndex).sort()
+  const sortedSelectorNameWithIndex = getNameWithIndex(sortedSelectors)
+
+  const rows = new Array(sortedSelectors.length)
+
+  const groupNames = Array.from(groupNameSet).sort()
+
+  keyNames.map(key => {
+    const obj = cube[key]
+    fillDrillDownRow(schema, obj, rows, key,
+      sortedSelectorNameWithIndex, aggregatorColumns, groupNames)
+  })
+
+  return {
+    transformed: rows,
+    groupNames: groupNames,
+    sortedSelectors: sortedSelectors,
+    sortedSelectorNameWithIndex: sortedSelectorNameWithIndex,
+  }
+}
+
+/** truly mutable style func. will return nothing, just modify `rows` */
+export function fillDrillDownRow(schema, obj, rows, key,
+                                 selectorNameWithIndex, aggrColumns, groupNames) {
+  /** when group is empty */
+  for(let i = 0; i < aggrColumns.length; i++) {
+    const row = {}
+    const aggrColumn = aggrColumns[i]
+    const aggrName = `${aggrColumn.name}(${aggrColumn.aggr})`
+
+    const value = getCubeValue(obj, aggrColumn.aggr, aggrName)
+    const selector = getSelectorName((schema.key) ? key : undefined, aggrColumns.length, aggrName)
+
+    const selectorIndex = selectorNameWithIndex[selector]
+    row.value = value
+    row.drillDown = []
+    row.selector = selector
+
+    if (schema.group) {
+      row.drillDown = []
+
+      for(let groupName of groupNames) {
+        const value = getCubeValue(obj[aggrName].children, aggrColumn.aggr, groupName)
+        row.drillDown.push({ group: groupName, value: value, })
+      }
+    }
+
+    rows[selectorIndex] = row
+  }
+}


[3/3] zeppelin git commit: [ZEPPELIN-2217] AdvancedTransformation for Visualization

Posted by mo...@apache.org.
[ZEPPELIN-2217] AdvancedTransformation for Visualization

### What is this PR for?

`AdvancedTransformation` has more detailed options while providing existing features of `PivotTransformation` and `ColumnselectorTransformation` which Zeppelin already has

![av_in_30sec](https://cloud.githubusercontent.com/assets/4968473/24037330/c9478e86-0b40-11e7-9886-1ffb85042a7a.gif)

Here are some features which advanced-transformation can provide.

1. **(screenshot)** multiple sub charts
2. **(screenshot)** parameter widgets: `input`, `checkbox`, `option`, `textarea`
3. **(screenshot)** expand/fold axis and parameter panels
4. **(screenshot)** clear axis and parameter panels
5. **(screenshot)** remove duplicated columns in an axis
6. **(screenshot)** limit column count in an axis
7. configurable char axes: `valueType`, `axisType`, `description`, ...
8. configurable chart parameters
9. lazy transformation
10. parsing parameters automatically based on their type: `int`, `float`, `string`, `JSON`
11. multiple transformation methods
12. re-initialize whole configuration based on spec hash.
13. **(screenshot)** shared axis

#### API Details: Spec

`AdvancedTransformation` requires `spec` which includes axis and parameter details for charts.

- Let's create 2 sub-charts called `simple-line` and `step-line`.
- Each sub chart can have different `axis` and `parameter` depending on their requirements.

```js
  constructor(targetEl, config) {
    super(targetEl, config)

    const spec = {
      charts: {
        'simple-line': {
          sharedAxis: true, /** set if you want to share axes between sub charts, default is `false` */
          axis: {
            'xAxis': { dimension: 'multiple', axisType: 'key', },
            'yAxis': { dimension: 'multiple', axisType: 'aggregator'},
            'category': { dimension: 'multiple', axisType: 'group', },
          },
          parameter: {
            'xAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of xAxis', },
            'yAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of yAxis', },
            'dashLength': { valueType: 'int', defaultValue: 0, description: 'the length of dash', },
          },
        },

        'step-line': {
          axis: {
            'xAxis': { dimension: 'single', axisType: 'unique', },
            'yAxis': { dimension: 'multiple', axisType: 'value', },
          },
          parameter: {
            'xAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of xAxis', },
            'yAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of yAxis', },
            'noStepRisers': { valueType: 'boolean', defaultValue: false, description: 'no risers in step line', widget: 'checkbox', },
        },

      },
    }

    this.transformation = new AdvancedTransformation(config, spec)
  }
```

####  API Details: Axis Spec

| Field Name | Available Values (type) | Description |
| --- | --- | --- |
|`dimension` | `single` | Axis can contains only 1 column |
|`dimension` | `multiple` | Axis can contains multiple columns |
|`axisType` | `key` | Column(s) in this axis will be used as `key` like in `PivotTransformation`. These columns will be served in `column.key` |
|`axisType` | `aggregator` | Column(s) in this axis will be used as `value` like in `PivotTransformation`. These columns will be served in `column.aggregator` |
|`axisType` | `group` | Column(s) in this axis will be used as `group` like in `PivotTransformation`. These columns will be served in `column.group` |
|`axisType` | (string) | Any string value can be used here. These columns will be served in `column.custom` |
|`maxAxisCount` | (int) | The maximum column count that this axis can contains. (unlimited if `undefined`) |
|`valueType` | (string) | Describe the value type just for annotation |

Here is an example.

```js
          axis: {
            'xAxis': { dimension: 'multiple', axisType: 'key',  },
            'yAxis': { dimension: 'multiple', axisType: 'aggregator'},
            'category': { dimension: 'multiple', axisType: 'group', maxAxisCount: 2, valueType: 'string', },
          },
```

####  API Details: Parameter Spec

| Field Name | Available Values (type) | Description |
| --- | --- | --- |
|`valueType` | `string` | Parameter which has string value |
|`valueType` | `int` | Parameter which has int value |
|`valueType` | `float` | Parameter which has float value |
|`valueType` | `boolean` | Parameter which has boolean value used with `checkbox` widget usually |
|`valueType` | `JSON` | Parameter which has JSON value used with `textarea` widget usually. `defaultValue` should be `""` (empty string). This ||`defaultValue` | (any) | Default value of this parameter. `JSON` type should have `""` (empty string) |
|`description` | (string) | Description of this parameter. This value will be parsed as HTML for pretty output |
|`widget` | `input` |  Use [input](https://developer.mozilla.org/en/docs/Web/HTML/Element/input) widget. This is the default widget (if `widget` is undefined)|
|`widget` | `checkbox` |  Use [checkbox](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox) widget. |
|`widget` | `textarea` |  Use [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) widget. |
|`widget` | `option` |  Use [select + option](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select) widget. This parameter should have `optionValues` field as well. |
|`optionValues` | (Array<string>) |  Available option values used with the `option` widget |

Here is an example.

```js
parameter: {
  // string type, input widget
  'xAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of xAxis', },

  // boolean type, checkbox widget
  'inverted': { widget: 'checkbox', valueType: 'boolean', defaultValue: false, description: 'invert x and y axes', },

  // string type, option widget with `optionValues`
  'graphType': { widget: 'option', valueType: 'string', defaultValue: 'line', description: 'graph type', optionValues: [ 'line', 'smoothedLine', 'step', ], },

  // HTML in `description`
  'dateFormat': { valueType: 'string', defaultValue: '', description: 'format of date (<a href="https://docs.amcharts.com/3/javascriptcharts/AmGraph#dateFormat">doc</a>) (e.g YYYY-MM-DD)', },

  // JSON type, textarea widget
  'yAxisGuides': { widget: 'textarea', valueType: 'JSON', defaultValue: '', description: 'guides of yAxis ', },
```

#### API Details: Transformer Spec

`AdvancedTransformation` supports 3 transformation methods. The return value will depend on the transformation method type.

```js
    const spec = {
      charts: {
        'simple': {
          /** default value of `transform.method` is the flatten cube.  */
          axis: { ... },
          parameter: { ... }
        },

        'cube-group': {
          transform: { method: 'cube', },
          axis: { ... },
          parameter: { ... },
        }

        'no-group': {
          transform: { method: 'raw', },
          axis: { ... },
          parameter: { ... },
        }
```

| Field Name | Available Values (type) | Description |
| --- | --- | --- |
|`method` | `object` |  designed for [amcharts: serial](https://www.amcharts.com/demos/date-based-data/) |
|`method` | `array` | designed for [highcharts: column](http://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/highcharts/demo/column-basic/) |
|`method` | `drill-down` | designed for [highcharts: drill-down](http://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/highcharts/demo/column-drilldown/) |
|`method` | `raw` | will return the original `tableData.rows` |

Whatever you specified as `transform.method`, the `transformer` value will be always function for lazy computation.

```js
// advanced-transformation.util#getTransformer

  if (transformSpec.method === 'raw') {
    transformer = () => { return rows; }
  } else if (transformSpec.method === 'array') {
    transformer = () => {
      ...
      return { ... }
    }
  }

```

#### Feature Details: Automatic parameter parsing

Advanced transformation will parse parameter values automatically based on their type: `int`, `float`, `string`, `JSON`

- See also `advanced-transformation-util.js#parseParameter`

#### Feature Details: re-initialize the whole configuration based on spec hash

```js
// advanced-transformation-util#initializeConfig

  const currentVersion = JSON.stringify(spec)
  if (!config.spec || !config.spec.version || config.spec.version !== currentVersion) {
    spec.version = currentVersion
    // reset config...
  }
```

#### Feature Details: Shared Axes

If you set `sharedAxis` to `true` in chart specification, then these charts will share their axes. (default is `false`)

```js
    const spec = {
      charts: {
        'column': {
          transform: { method: 'array', },
          sharedAxis: true,
          axis: { ... },
          parameter: { ... },
        },

        'stacked': {
          transform: { method: 'array', },
          sharedAxis: true,
          axis: { ... }
          parameter: { ... },
        },
```

![sharedaxis](https://cloud.githubusercontent.com/assets/4968473/24207116/6999ad8a-0f63-11e7-8b61-273b712612fc.gif)

#### API Details: Usage in Visualization#render()

Let's assume that we want to create 2 sub-charts called `basic` and `no-group`.

- https://github.com/1ambda/zeppelin-ultimate-line-chart (an practical example)

```js
  drawBasicChart(parameter, column, transformer) {
    const { ... } = transformer()
  }

  drawNoGroupChart(parameter, column, transformer) {
    const { ... } = transformer()
  }

  render(data) {
    const { chart, parameter, column, transformer, } = data

    if (chart === 'basic') {
      this.drawBasicChart(parameter, column, transformer)
    } else if (chart === 'no-group') {
      this.drawNoGroupChart(parameter, column, transformer)
    }
  }
```

### What type of PR is it?
[Feature]

### Todos

NONE

### What is the Jira issue?

[ZEPPELIN-2217](https://issues.apache.org/jira/browse/ZEPPELIN-2217)

### How should this be tested?

1. Clone https://github.com/1ambda/zeppelin-ultimate-line-chart
2. Create a symbolic link `ultimate-line-chart.json` into `$ZEPPELIN_HOME/helium`
3. Modify the `artifact` value to proper absolute path considering your local machine.
4. Install the above visualization in `localhost:9000/#helium`
5. Test it

### Screenshots (if appropriate)

#### 1. *(screenshot)* multiple sub charts

![av_multiple_charts](https://cloud.githubusercontent.com/assets/4968473/24034638/7b84dba0-0b35-11e7-989d-059ccc87f968.gif)

#### 2. *(screenshot)* parameter widgets: `input`, `checkbox`, `option`, `textarea`

![av_widgets_new](https://cloud.githubusercontent.com/assets/4968473/24034652/88679d6c-0b35-11e7-835a-3970d7124850.gif)

#### 3. *(screenshot)* expand/fold axis and parameter panels

![av_fold_expand](https://cloud.githubusercontent.com/assets/4968473/24034653/8a634ddc-0b35-11e7-9851-15280a6b5fd3.gif)

#### 4. *(screenshot)* clear axis and parameter panels

![av_clean_buttons](https://cloud.githubusercontent.com/assets/4968473/24034654/8d3dc14a-0b35-11e7-98c7-3aeddce6d80a.gif)

#### 5. *(screenshot)* remove duplicated columns in an axis

![av_duplicated_columns](https://cloud.githubusercontent.com/assets/4968473/24034657/910f4d20-0b35-11e7-9e9b-d9e2f799a5dd.gif)

#### 6. *(screenshot)* limit column count in an axis

![av_maxaxiscount](https://cloud.githubusercontent.com/assets/4968473/24034679/a5e8eb34-0b35-11e7-89cd-070f3790d511.gif)

### Questions:
* Does the licenses files need update? - NO
* Is there breaking changes for older versions? - NO
* Does this needs documentation? - NO

Author: 1ambda <1a...@gmail.com>

Closes #2098 from 1ambda/ZEPPELIN-2217/advanced-transformation and squashes the following commits:

6cde7c9 [1ambda] fix reset params when spec change
c75a3f2 [1ambda] fix: Reset persisted axis
6a2130a [1ambda] fix: clear config only when axis changed
5464e84 [1ambda] fix: Optimize array 2 key method
9beb1e7 [1ambda] fix: Type error
2408225 [1ambda] test: Add test for array 2key
bf56761 [1ambda] feat: Add array:2-key transform method
7c6768f [1ambda] feat: Use axisSpec.desc as tooltip
f98d4c9 [1ambda] fix: Remove invalid key  prop
5cf2ece [1ambda] feat: Add minAxisCount
4887800 [1ambda] fix: Remove local module yarn caches
3e29572 [1ambda] refactor: copyModule func
c91a033 [1ambda] fix: Set yarn cache dir in helium-bundles
04b5140 [1ambda] fix: Import a-tr
0a876cf [1ambda] docs: Update index.md
380b1af [1ambda] docs: Fix typo and add desc for existing trs
908214b [1ambda] docs: Move experimental tags
a009627 [1ambda] feat: Allow dup aggr axis
3b44e92 [1ambda] fix: Remove unuse const
ab6c22e [1ambda] test: Add test for drill-down method
756107a [1ambda] test: Add array transformation method
d819c73 [1ambda] test: Add object method
bf00fba [1ambda] test: Add MockTableData
39fe5ae [1ambda] test: Add test for getColumnsFromAxis
4c393b4 [1ambda] fix: Add polyfill for es6 funcs in test
e92c787 [1ambda] test: Add test for rmDup, aplMaxAxisCount
843f45d [1ambda] test: Add test for getCurrent* funcs
ae5277c [1ambda] test: Add test for initializeConfig
c14a9dc7 [1ambda] test: Add tests for widget, params
c510af1 [1ambda] docs: Add doc for Transformation
52db37b [1ambda] feat: Show panel menus only when opened
17ad4a4 [1ambda] feat: Support chartChanged, parameterChanged
c0d33d3 [1ambda] fix: Sort selectors in drilldown method
cfd6fef [1ambda] feat: sharedAxis
9af80ce [1ambda] style: Indent
79b5654 [1ambda] fix: return the same info in transform
7bee464 [1ambda] fix: Keynames
ee8788e [1ambda] feat: Support drill-down
666025a [1ambda] fix: DON'T reset current chart
ae1891f [1ambda] add array:key transform
4167a2e [1ambda] fix: Sort keyNames
912b5b7 [1ambda] fix: Persist initialized config
f1f6b0c [1ambda] feat: Support ARRAY transform.method
812f9a2 [1ambda] fix: Set proper aggr value when 0 group
20f9437 [1ambda] fix: getCube func
25d51a9 [1ambda] DON'T display aggr.name when aggrColumns.length == 1
f37e13d [1ambda] fix: Add 'object' transform.method
da2370c [1ambda] fix: Add resetAxis, Param funcs
2370682 [1ambda] fix: average is not caculated correctly
dd08e38 [1ambda] fix: Set param panel height to 400
881695a [1ambda] feat: clear chart, param separately
4d0d62b [1ambda] fix: DON'T clean panel config
92676d1 [1ambda] fix: limit parameter panel height to 370
cc29060 [1ambda] feat: parse param description as HTML
9a2d227 [1ambda] fix: Stop event propagation in widgets
fcc625c [1ambda] feat: Automatic param parsing
b4d774c [1ambda] fix: Dont close param panel when enter
088705b [1ambda] refactor: Remove util and add Widget funcs
bf88b4f [1ambda] feat: textare widget and update hook
4e73012 [1ambda] feat: widget checkbox
11b7eaa [1ambda] feat: option widget
5d3efc9 [1ambda] fix: Change panel header
b1d9d31 [1ambda] feat: Save and close with enter key
53f508c [1ambda] feat: custom axisSpec
0dbc431 [1ambda] feat: Support transformer
94d837a [1ambda] feat: Automatic spec versioning
74b8b4e [1ambda] fix: Duplicated radio btn id, name
5b88f08 [1ambda] fix: Modify margin of subchart radio btns
019892c [1ambda] feat: Support transform: flatten
0484e1e [1ambda] feat: Support maxAxisCount in axisSpec
936901b [1ambda] feat: Support undefined valueType in axisSpec
7a454ff [1ambda] feat: Cube Transformation
f0ed02f [1ambda] feat: Support same axis types
49985c6 [1ambda] refactor: Refine axis, param spec
d89e223 [1ambda] feat: advanced-transformation-api
75569ce [1ambda] feat: Support multiple charts in UI
e1fcc2e [1ambda] feat: Support multiple charts
97be629 [1ambda] fix: Add singleDimensionAggregatorChanged
676bd7e [1ambda] refactor: Refine transform API
9fb398e [1ambda] feat: Add clearConfig
a8a4fb1 [1ambda] refactor: Add getAxisInSingleDimension func
9768ecf [1ambda] feat: Add groupBase axis option
91ae54d [1ambda] fix: Overflow issue in single aggr
10c80fc [1ambda] feat: AdvancedTransformation


Project: http://git-wip-us.apache.org/repos/asf/zeppelin/repo
Commit: http://git-wip-us.apache.org/repos/asf/zeppelin/commit/45cc8a9e
Tree: http://git-wip-us.apache.org/repos/asf/zeppelin/tree/45cc8a9e
Diff: http://git-wip-us.apache.org/repos/asf/zeppelin/diff/45cc8a9e

Branch: refs/heads/master
Commit: 45cc8a9e8a23a57271dec384245d0012a0e5e608
Parents: 2173b40
Author: 1ambda <1a...@gmail.com>
Authored: Tue Apr 11 23:14:46 2017 +0900
Committer: Lee moon soo <mo...@apache.org>
Committed: Sat Apr 15 07:34:27 2017 +0900

----------------------------------------------------------------------
 docs/_includes/themes/zeppelin/_navigation.html |    9 +-
 docs/development/writingzeppelinapplication.md  |    4 +-
 docs/development/writingzeppelinspell.md        |    4 +-
 .../development/writingzeppelinvisualization.md |    4 +-
 ...itingzeppelinvisualization_transformation.md |  281 +++
 docs/index.md                                   |    9 +-
 zeppelin-web/karma.conf.js                      |    6 +-
 zeppelin-web/package.json                       |    2 +-
 .../advanced-transformation-setting.html        |  280 +++
 .../tabledata/advanced-transformation-util.js   | 1259 +++++++++++++
 .../advanced-transformation-util.test.js        | 1746 ++++++++++++++++++
 .../app/tabledata/advanced-transformation.js    |  230 +++
 zeppelin-web/src/index.js                       |    1 +
 .../zeppelin/helium/HeliumBundleFactory.java    |  101 +-
 .../helium/HeliumBundleFactoryTest.java         |    2 +-
 15 files changed, 3878 insertions(+), 60 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/docs/_includes/themes/zeppelin/_navigation.html
----------------------------------------------------------------------
diff --git a/docs/_includes/themes/zeppelin/_navigation.html b/docs/_includes/themes/zeppelin/_navigation.html
index 623fac3..4e49a1a 100644
--- a/docs/_includes/themes/zeppelin/_navigation.html
+++ b/docs/_includes/themes/zeppelin/_navigation.html
@@ -117,10 +117,11 @@
                 <li><a href="{{BASE_PATH}}/security/notebook_authorization.html">Notebook Authorization</a></li>
                 <li><a href="{{BASE_PATH}}/security/datasource_authorization.html">Data Source Authorization</a></li>
                 <li role="separator" class="divider"></li>
-                <li class="title"><span><b>Helium Framework</b><span></li>
-                <li><a href="{{BASE_PATH}}/development/writingzeppelinapplication.html">Writing Zeppelin Application (Experimental)</a></li>
-                <li><a href="{{BASE_PATH}}/development/writingzeppelinspell.html">Writing Zeppelin Spell (Experimental)</a></li>
-                <li><a href="{{BASE_PATH}}/development/writingzeppelinvisualization.html">Writing Zeppelin Visualization (Experimental)</a></li>
+                <li class="title"><span><b>Helium Framework (Experimental)</b></span></li>
+                <li><a href="{{BASE_PATH}}/development/writingzeppelinapplication.html">Writing Zeppelin Application</a></li>
+                <li><a href="{{BASE_PATH}}/development/writingzeppelinspell.html">Writing Zeppelin Spell</a></li>
+                <li><a href="{{BASE_PATH}}/development/writingzeppelinvisualization.html">Writing Zeppelin Visualization: Basics</a></li>
+                <li><a href="{{BASE_PATH}}/development/writingzeppelinvisualization_transformation.html">Writing Zeppelin Visualization: Transformation</a></li>
                 <li role="separator" class="divider"></li>
                 <li class="title"><span><b>Advanced</b><span></li>
                 <li><a href="{{BASE_PATH}}/install/virtual_machine.html">Zeppelin on Vagrant VM</a></li>

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/docs/development/writingzeppelinapplication.md
----------------------------------------------------------------------
diff --git a/docs/development/writingzeppelinapplication.md b/docs/development/writingzeppelinapplication.md
index 7048cb3..59f980a 100644
--- a/docs/development/writingzeppelinapplication.md
+++ b/docs/development/writingzeppelinapplication.md
@@ -1,6 +1,6 @@
 ---
 layout: page
-title: "Writing a new Application(Experimental)"
+title: "Writing a new Application"
 description: "Apache Zeppelin Application is a package that runs on Interpreter process and displays it's output inside of the notebook. Make your own Application in Apache Zeppelin is quite easy."
 group: development
 ---
@@ -19,7 +19,7 @@ limitations under the License.
 -->
 {% include JB/setup %}
 
-# Writing a new Application (Experimental)
+# Writing a new Application
 
 <div id="toc"></div>
 

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/docs/development/writingzeppelinspell.md
----------------------------------------------------------------------
diff --git a/docs/development/writingzeppelinspell.md b/docs/development/writingzeppelinspell.md
index 02a2301..1eefe83 100644
--- a/docs/development/writingzeppelinspell.md
+++ b/docs/development/writingzeppelinspell.md
@@ -1,6 +1,6 @@
 ---
 layout: page
-title: "Writing a new Spell(Experimental)"
+title: "Writing a new Spell"
 description: "Spell is a kind of interpreter that runs on browser not on backend. So, technically it's the frontend interpreter. "
 group: development
 ---
@@ -19,7 +19,7 @@ limitations under the License.
 -->
 {% include JB/setup %}
 
-# Writing a new Spell (Experimental)
+# Writing a new Spell
 
 <div id="toc"></div>
 

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/docs/development/writingzeppelinvisualization.md
----------------------------------------------------------------------
diff --git a/docs/development/writingzeppelinvisualization.md b/docs/development/writingzeppelinvisualization.md
index 5ccd970..5a0601f 100644
--- a/docs/development/writingzeppelinvisualization.md
+++ b/docs/development/writingzeppelinvisualization.md
@@ -1,6 +1,6 @@
 ---
 layout: page
-title: "Writing a new Visualization(Experimental)"
+title: "Writing a new Visualization"
 description: "Apache Zeppelin Visualization is a pluggable package that can be loaded/unloaded on runtime through Helium framework in Zeppelin. A Visualization is a javascript npm package and user can use them just like any other built-in visualization in a note."
 group: development
 ---
@@ -19,7 +19,7 @@ limitations under the License.
 -->
 {% include JB/setup %}
 
-# Writing a new Visualization (Experimental)
+# Writing a new Visualization
 
 <div id="toc"></div>
 

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/docs/development/writingzeppelinvisualization_transformation.md
----------------------------------------------------------------------
diff --git a/docs/development/writingzeppelinvisualization_transformation.md b/docs/development/writingzeppelinvisualization_transformation.md
new file mode 100644
index 0000000..22bf130
--- /dev/null
+++ b/docs/development/writingzeppelinvisualization_transformation.md
@@ -0,0 +1,281 @@
+---
+layout: page
+title: "Transformations for Zeppelin Visualization"
+description: "Description for Transformations"
+group: development
+---
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+{% include JB/setup %}
+
+# Transformations for Zeppelin Visualization
+
+<div id="toc"></div>
+
+## Overview 
+
+Transformations 
+
+- **renders** setting which allows users to set columns and 
+- **transforms** table rows according to the configured columns.
+
+Zeppelin provides 4 types of transformations.
+
+## 1. PassthroughTransformation
+
+`PassthroughTransformation` is the simple transformation which does not convert original tabledata at all.
+
+See [passthrough.js](https://github.com/apache/zeppelin/blob/master/zeppelin-web/src/app/tabledata/passthrough.js)
+
+## 2. ColumnselectorTransformation
+
+`ColumnselectorTransformation` is uses when you need `N` axes but do not need aggregation. 
+
+See [columnselector.js](https://github.com/apache/zeppelin/blob/master/zeppelin-web/src/app/tabledata/columnselector.js)
+
+## 3. PivotTransformation
+
+`PivotTransformation` provides group by and aggregation. Every chart using `PivotTransformation` has 3 axes. `Keys`, `Groups` and `Values`.
+
+See [pivot.js](https://github.com/apache/zeppelin/blob/master/zeppelin-web/src/app/tabledata/pivot.js)
+
+## 4. AdvancedTransformation
+
+`AdvancedTransformation` has more detailed options while providing existing features of `PivotTransformation` and `ColumnselectorTransformation`
+
+- multiple sub charts
+- configurable chart axes
+- parameter widgets: `input`, `checkbox`, `option`, `textarea`
+- parsing parameters automatically based on their types
+- expand / fold axis and parameter panels
+- multiple transformation methods while supporting lazy converting 
+- re-initialize the whole configuration based on spec hash.
+
+### Spec 
+
+`AdvancedTransformation` requires `spec` which includes axis and parameter details for charts.
+
+Let's create 2 sub-charts called `line` and `no-group`. Each sub chart can have different axis and parameter depending on their requirements.
+
+<br/>
+
+```js
+class AwesomeVisualization extends Visualization {
+  constructor(targetEl, config) {
+    super(targetEl, config)
+  
+    const spec = {
+      charts: {
+        'line': {
+          transform: { method: 'object', },
+          sharedAxis: false, /** set if you want to share axes between sub charts, default is `false` */
+          axis: {
+            'xAxis': { dimension: 'multiple', axisType: 'key', description: 'serial', },
+            'yAxis': { dimension: 'multiple', axisType: 'aggregator', description: 'serial', },
+            'category': { dimension: 'multiple', axisType: 'group', description: 'categorical', },
+          },
+          parameter: {
+            'xAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of xAxis', },
+            'yAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of yAxis', },
+            'lineWidth': { valueType: 'int', defaultValue: 0, description: 'width of line', },
+          },
+        },
+  
+        'no-group': {
+          transform: { method: 'object', },
+          sharedAxis: false,
+          axis: {
+            'xAxis': { dimension: 'single', axisType: 'key', },
+            'yAxis': { dimension: 'multiple', axisType: 'value', },
+          },
+          parameter: {
+            'xAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of xAxis', },
+            'yAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of yAxis', },
+        },
+      },
+    }
+
+    this.transformation = new AdvancedTransformation(config, spec)
+  }
+  
+  ...
+  
+  // `render` will be called whenever `axis` or `parameter` is changed 
+  render(data) {
+    const { chart, parameter, column, transformer, } = data
+   
+    if (chart === 'line') {
+      const transformed = transformer()
+      // draw line chart 
+    } else if (chart === 'no-group') {
+      const transformed = transformer()
+      // draw no-group chart 
+    }
+  }
+}
+```
+
+<br/>
+
+### Spec: `axis` 
+
+| Field Name | Available Values (type) | Description |
+| --- | --- | --- |
+|`dimension` | `single` | Axis can contains only 1 column |
+|`dimension` | `multiple` | Axis can contains multiple columns |
+|`axisType` | `key` | Column(s) in this axis will be used as `key` like in `PivotTransformation`. These columns will be served in `column.key` |
+|`axisType` | `aggregator` | Column(s) in this axis will be used as `value` like in `PivotTransformation`. These columns will be served in `column.aggregator` |
+|`axisType` | `group` | Column(s) in this axis will be used as `group` like in `PivotTransformation`. These columns will be served in `column.group` |
+|`axisType` | (string) | Any string value can be used here. These columns will be served in `column.custom` |
+|`maxAxisCount` (optional) | (int) | The max number of columns that this axis can contain. (unlimited if `undefined`) |
+|`minAxisCount` (optional) | (int) | The min number of columns that this axis should contain to draw chart. (`1` in case of single dimension) |
+|`description` (optional) | (string) | Description for the axis. |
+
+<br/>
+
+Here is an example.
+
+```js
+axis: {
+  'xAxis': { dimension: 'multiple', axisType: 'key',  },
+  'yAxis': { dimension: 'multiple', axisType: 'aggregator'},
+  'category': { dimension: 'multiple', axisType: 'group', maxAxisCount: 2, valueType: 'string', },
+},
+```
+
+<br/>
+
+### Spec: `sharedAxis` 
+
+If you set `sharedAxis: false` for sub charts, then their axes are persisted in global space (shared). It's useful for when you creating multiple sub charts sharing their axes but have different parameters. For example, 
+
+- `basic-column`, `stacked-column`, `percent-column`
+- `pie` and `donut`
+
+<br/>
+
+Here is an example.
+
+```js
+    const spec = {
+      charts: {
+        'column': {
+          transform: { method: 'array', },
+          sharedAxis: true,
+          axis: { ... },
+          parameter: { ... },
+        },
+
+        'stacked': {
+          transform: { method: 'array', },
+          sharedAxis: true,
+          axis: { ... }
+          parameter: { ... },
+        },
+```
+
+<br/>
+
+### Spec: `parameter` 
+
+| Field Name | Available Values (type) | Description |
+| --- | --- | --- |
+|`valueType` | `string` | Parameter which has string value |
+|`valueType` | `int` | Parameter which has int value |
+|`valueType` | `float` | Parameter which has float value |
+|`valueType` | `boolean` | Parameter which has boolean value used with `checkbox` widget usually |
+|`valueType` | `JSON` | Parameter which has JSON value used with `textarea` widget usually. `defaultValue` should be `""` (empty string). This ||`defaultValue` | (any) | Default value of this parameter. `JSON` type should have `""` (empty string) |
+|`description` | (string) | Description of this parameter. This value will be parsed as HTML for pretty output |
+|`widget` | `input` |  Use [input](https://developer.mozilla.org/en/docs/Web/HTML/Element/input) widget. This is the default widget (if `widget` is undefined)|
+|`widget` | `checkbox` |  Use [checkbox](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox) widget. |
+|`widget` | `textarea` |  Use [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) widget. |
+|`widget` | `option` |  Use [select + option](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select) widget. This parameter should have `optionValues` field as well. |
+|`optionValues` | (Array<string>) |  Available option values used with the `option` widget |
+
+<br/>
+
+Here is an example.
+
+```js
+parameter: {
+  // string type, input widget
+  'xAxisUnit': { valueType: 'string', defaultValue: '', description: 'unit of xAxis', },
+
+  // boolean type, checkbox widget
+  'inverted': { widget: 'checkbox', valueType: 'boolean', defaultValue: false, description: 'invert x and y axes', },
+
+  // string type, option widget with `optionValues`
+  'graphType': { widget: 'option', valueType: 'string', defaultValue: 'line', description: 'graph type', optionValues: [ 'line', 'smoothedLine', 'step', ], },
+
+  // HTML in `description`
+  'dateFormat': { valueType: 'string', defaultValue: '', description: 'format of date (<a href="https://docs.amcharts.com/3/javascriptcharts/AmGraph#dateFormat">doc</a>) (e.g YYYY-MM-DD)', },
+ 
+  // JSON type, textarea widget
+  'yAxisGuides': { widget: 'textarea', valueType: 'JSON', defaultValue: '', description: 'guides of yAxis ', },
+```
+
+<br/>
+
+### Spec: `transform`
+
+| Field Name | Available Values (type) | Description |
+| --- | --- | --- |
+|`method` | `object` |  designed for rows requiring object manipulation | 
+|`method` | `array` |  designed for rows requiring array manipulation | 
+|`method` | `array:2-key` |  designed for xyz charts (e.g bubble chart) | 
+|`method` | `drill-down` |  designed for drill-down charts | 
+|`method` | `raw` | will return the original `tableData.rows` | 
+
+<br/>
+
+Whatever you specified as `transform.method`, the `transformer` value will be always function for lazy computation. 
+
+```js
+// advanced-transformation.util#getTransformer
+
+if (transformSpec.method === 'raw') {
+  transformer = () => { return rows; }
+} else if (transformSpec.method === 'array') {
+  transformer = () => {
+    ...
+    return { ... }
+  }
+}
+```
+
+Here is actual usage.
+
+```js
+class AwesomeVisualization extends Visualization {
+  constructor(...) { /** setup your spec */ }
+  
+  ... 
+  
+  // `render` will be called whenever `axis` or `parameter` are changed
+  render(data) {
+    const { chart, parameter, column, transformer, } = data
+   
+    if (chart === 'line') {
+      const transformed = transformer()
+      // draw line chart 
+    } else if (chart === 'no-group') {
+      const transformed = transformer()
+      // draw no-group chart 
+    }
+  }
+  
+  ...
+}
+```
+

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/docs/index.md
----------------------------------------------------------------------
diff --git a/docs/index.md b/docs/index.md
index 8065aef..043538d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -172,10 +172,11 @@ Join to our [Mailing list](https://zeppelin.apache.org/community.html) and repor
   * [Shiro Authentication](./security/shiroauthentication.html)
   * [Notebook Authorization](./security/notebook_authorization.html)
   * [Data Source Authorization](./security/datasource_authorization.html)
-* Helium Framework
-  * [Writing Zeppelin Application (Experimental)](./development/writingzeppelinapplication.html)
-  * [Writing Zeppelin Spell (Experimental)](./development/writingzeppelinspell.html)
-  * [Writing Zeppelin Visualization (Experimental)](./development/writingzeppelinvisualization.html)
+* Helium Framework (Experimental)
+  * [Writing Zeppelin Application](./development/writingzeppelinapplication.html)
+  * [Writing Zeppelin Spell](./development/writingzeppelinspell.html)
+  * [Writing Zeppelin Visualization: Basic](./development/writingzeppelinvisualization.html)
+  * [Writing Zeppelin Visualization: Transformation](./development/writingzeppelinvisualization_transformation.html)
 * Advanced
   * [Apache Zeppelin on Vagrant VM](./install/virtual_machine.html)
   * [Zeppelin on Spark Cluster Mode (Standalone via Docker)](./install/spark_cluster_mode.html#spark-standalone-mode)

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-web/karma.conf.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/karma.conf.js b/zeppelin-web/karma.conf.js
index b24e36a..f47741a 100644
--- a/zeppelin-web/karma.conf.js
+++ b/zeppelin-web/karma.conf.js
@@ -37,6 +37,9 @@ module.exports = function(config) {
 
     // list of files / patterns to load in the browser
     files: [
+      // for polyfill
+      'node_modules/babel-polyfill/dist/polyfill.js',
+
       // bower:js
       'bower_components/jquery/dist/jquery.js',
       'bower_components/es5-shim/es5-shim.js',
@@ -124,8 +127,7 @@ module.exports = function(config) {
 
     preprocessors: {
       'src/*/{*.js,!(test)/**/*.js}': 'coverage',
-      'src/index.js': ['webpack', 'sourcemap',],
-      'src/**/*.test.js': ['webpack', 'sourcemap',],
+      'src/**/*.js': ['webpack', 'sourcemap',],
     },
 
     coverageReporter: {

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-web/package.json
----------------------------------------------------------------------
diff --git a/zeppelin-web/package.json b/zeppelin-web/package.json
index 6e1252b..99dc058 100644
--- a/zeppelin-web/package.json
+++ b/zeppelin-web/package.json
@@ -16,7 +16,7 @@
     "dev:watch": "grunt watch-webpack-dev",
     "dev": "npm-run-all --parallel dev:server dev:watch",
     "visdev": "npm-run-all --parallel visdev:server dev:watch",
-    "pretest": "npm install karma-phantomjs-launcher",
+    "pretest": "npm install karma-phantomjs-launcher babel-polyfill",
     "test": "karma start karma.conf.js"
   },
   "dependencies": {

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/45cc8a9e/zeppelin-web/src/app/tabledata/advanced-transformation-setting.html
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/tabledata/advanced-transformation-setting.html b/zeppelin-web/src/app/tabledata/advanced-transformation-setting.html
new file mode 100644
index 0000000..8393bf3
--- /dev/null
+++ b/zeppelin-web/src/app/tabledata/advanced-transformation-setting.html
@@ -0,0 +1,280 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<div class="panel panel-default" style="margin-top: 10px; margin-bottom: 11px;">
+
+  <!-- panel: axis (configured column) information -->
+  <div class="panel-heading"
+       style="padding: 6px 12px 6px 12px; font-size: 13px;">
+    <span style="vertical-align: middle; display: inline-block; margin-top: 3px;">Charts</span>
+    <span style="float: right;">
+       <div class="btn-group" role="group" aria-label="...">
+         <div type="button" ng-click="resetAxisConfig()"
+              ng-if="config.panel.columnPanelOpened"
+              class="btn btn-default" style="padding: 2px 5px 2px 5px;">
+           <i class="fa fa-trash-o" aria-hidden="true"></i>
+         </div>
+         <div type="button" ng-if="config.panel.columnPanelOpened"
+              ng-click="toggleColumnPanel()"
+              class="btn btn-default" style="padding: 2px 5px 2px 5px;">
+           <i class="fa fa-minus" style="font-size: 12px;" aria-hidden="true"></i>
+         </div>
+         <div type="button" ng-if="!config.panel.columnPanelOpened"
+              ng-click="toggleColumnPanel()"
+              class="btn btn-default" style="padding: 2px 5px 2px 5px;">
+           <i class="fa fa-expand" style="font-size: 11px;" aria-hidden="true"></i>
+         </div>
+       </div>
+    </span>
+    <div style="clear: both;"></div> <!-- to fix previous span which has float: right -->
+  </div>
+  <div class="panel-body" ng-if="config.panel.columnPanelOpened"
+       style="padding: 8px; margin-top: 3px;">
+    <ul class="noDot">
+      <li class="liVertical" ng-repeat="chart in config.chart.available">
+        <label class="radio-inline">
+          <input type="radio" style="margin-top: 1px; margin-left: -17px;"
+                 ng-checked="config.chart.current === chart"
+                 ng-click="chartChanged(chart)" value="{{chart}}" />
+          <span style="vertical-align: middle;">
+            {{chart}} {{useSharedAxis(chart) ? '(shared)' : ''}}
+          </span>
+        </label>
+      </li>
+    </ul>
+  </div>
+
+  <!-- panel: available columns -->
+  <div class="panel-heading" ng-if="config.panel.columnPanelOpened"
+       style="padding: 6px 12px 6px 12px; font-size: 13px; border-top: 1px solid #ddd; border-top-left-radius: 0px; border-top-right-radius: 0px;">
+    <span>Available Columns</span>
+  </div>
+  <div class="panel-body" ng-if="config.panel.columnPanelOpened"
+       style="padding: 8px; margin-top: 3px;">
+    <ul class="noDot">
+      <li class="liVertical" ng-repeat="column in columns">
+        <div class="btn btn-default btn-xs"
+             style="background-color: #EFEFEF;"
+             data-drag="true"
+             data-jqyoui-options="{revert: 'invalid', helper: 'clone'}"
+             ng-model="columns"
+             jqyoui-draggable="{index: {{$index}}, placeholder: 'keep'}">
+          {{column.name | limitTo: 30}}{{column.name.length > 30 ? '...' : ''}}
+        </div>
+      </li>
+    </ul>
+  </div>
+
+  <!-- panel: axis (configured columns) -->
+  <hr style="margin: 1px;" ng-if="config.panel.columnPanelOpened" />
+  <div class="panel-body" ng-if="config.panel.columnPanelOpened"
+       style="margin-top: 7px; padding-top: 9px; padding-bottom: 4px;">
+    <div class="row">
+      <div class="col-sm-4 col-md-3"
+           ng-repeat="axisSpec in config.axisSpecs[config.chart.current]">
+        <div class="columns lightBold">
+          <!-- axis name -->
+          <span class="label label-default"
+                uib-tooltip="{{axisSpec.description ? axisSpec.description : ''}}"
+                style="font-weight: 300; font-size: 13px; margin-left: 1px;">
+            {{getAxisAnnotation(axisSpec)}}
+          </span>
+          <span class="label label-default"
+                ng-style="getAxisTypeAnnotationColor(axisSpec)"
+                style="font-weight: 300; font-size: 13px; margin-left: 3px;">
+            {{getAxisTypeAnnotation(axisSpec)}}
+          </span>
+
+          <!-- axis box: in case of single dimension -->
+          <ul data-drop="true"
+              ng-if="isSingleDimensionAxis(axisSpec)"
+              ng-model="config.axis[config.chart.current][axisSpec.name]"
+              jqyoui-droppable="{onDrop:'axisChanged(axisSpec)'}"
+              class="list-unstyled"
+              style="height:36px; border-radius: 6px; margin-top: 7px; overflow: visible !important;">
+            <li ng-if="config.axis[config.chart.current][axisSpec.name]">
+
+              <!-- in case of axis is single dimension and not aggregator -->
+              <div ng-if="!isAggregatorAxis(axisSpec)"
+                   class="btn btn-default btn-xs"
+                   style="background-color: #EFEFEF;">
+                {{ getSingleDimensionAxis(axisSpec).name }}
+                <span class="fa fa-close" ng-click="removeFromAxis(null, axisSpec)"></span>
+              </div>
+
+              <!-- in case of axis is single dimension and aggregator -->
+              <div class="btn-group">
+                <div ng-if="isAggregatorAxis(axisSpec)"
+                     class="btn btn-default btn-xs dropdown-toggle"
+                     style="background-color: #EFEFEF; "
+                     type="button" data-toggle="dropdown">
+                  {{getSingleDimensionAxis(axisSpec).name | limitTo: 30}}{{getSingleDimensionAxis(axisSpec).name > 30 ? '...' : ''}}
+                  <span style="color:#717171;">
+                    <span class="lightBold" style="text-transform: uppercase;">{{getSingleDimensionAxis(axisSpec).aggr}}</span>
+                  </span>
+                  <span class="fa fa-close" ng-click="removeFromAxis(null, axisSpec)"></span>
+                </div>
+                <ul class="dropdown-menu" role="menu">
+                  <li ng-click="aggregatorChanged(null, axisSpec, 'sum')"><a>sum</a></li>
+                  <li ng-click="aggregatorChanged(null, axisSpec, 'count')"><a>count</a></li>
+                  <li ng-click="aggregatorChanged(null, axisSpec, 'avg')"><a>avg</a></li>
+                  <li ng-click="aggregatorChanged(null, axisSpec, 'min')"><a>min</a></li>
+                  <li ng-click="aggregatorChanged(null, axisSpec, 'max')"><a>max</a></li>
+                </ul>
+              </div>
+
+            </li>
+          </ul>
+
+          <!-- axis box: in case of multiple dimensions -->
+          <ul data-drop="true"
+              ng-if="!isSingleDimensionAxis(axisSpec) "
+              ng-model="config.axis[config.chart.current][axisSpec.name]"
+              jqyoui-droppable="{multiple: true, onDrop:'axisChanged(axisSpec)'}"
+              class="list-unstyled"
+              style="height: 108px; border-radius: 6px; margin-top: 7px; overflow: auto !important;">
+
+            <span ng-repeat="col in config.axis[config.chart.current][axisSpec.name]">
+
+              <!-- in case of axis is multiple dimensions and not aggregator -->
+              <span ng-if="!isAggregatorAxis(axisSpec)"
+                    class="btn btn-default btn-xs"
+                    style="background-color: #EFEFEF; margin: 2px 0px 0px 2px;">
+                {{col.name}}
+                <span class="fa fa-close" ng-click="removeFromAxis($index, axisSpec)"></span>
+              </span>
+
+              <!-- in case of axis is multiple dimension and aggregator -->
+              <span class="btn-group">
+                <span ng-if="isAggregatorAxis(axisSpec)"
+                      class="btn btn-default btn-xs dropdown-toggle"
+                      style="background-color: #EFEFEF; margin: 2px 0px 0px 2px;"
+                      type="button" data-toggle="dropdown">
+                  {{col.name | limitTo: 30}}{{col.name.length > 30 ? '...' : ''}}
+                  <span style="color:#717171; margin: 0px;">
+                    <span class="lightBold"
+                          style="text-transform: uppercase; margin: 0px;">{{col.aggr}}
+                    </span>
+                  </span>
+                  <span class="fa fa-close" style="margin: 0px;" ng-click="removeFromAxis($index, axisSpec)"></span>
+                </span>
+                <ul class="dropdown-menu" role="menu">
+                  <li ng-click="aggregatorChanged($index, axisSpec, 'sum')"><a>sum</a></li>
+                  <li ng-click="aggregatorChanged($index, axisSpec, 'count')"><a>count</a></li>
+                  <li ng-click="aggregatorChanged($index, axisSpec, 'avg')"><a>avg</a></li>
+                  <li ng-click="aggregatorChanged($index, axisSpec, 'min')"><a>min</a></li>
+                  <li ng-click="aggregatorChanged($index, axisSpec, 'max')"><a>max</a></li>
+                </ul>
+              </span>
+
+            </span>
+          </ul>
+
+        </div>
+      </div>
+    </div>
+  </div>
+
+</div>
+
+<!-- panel: parameter information -->
+<div class="panel panel-default">
+
+  <div class="panel-heading" style="padding: 6px 12px 6px 12px; font-size: 13px;">
+    <span style="vertical-align: middle; display: inline-block; margin-top: 3px;">Parameters</span>
+    <span style="float: right;">
+      <div class="btn-group" role="group" aria-label="...">
+        <div type="button" ng-click="parameterChanged()"
+             ng-if="config.panel.parameterPanelOpened"
+             class="btn btn-default" style="padding: 2px 5px 2px 5px;">
+          <i class="fa fa-floppy-o" aria-hidden="true"></i>
+        </div>
+        <div type="button" ng-click="resetParameterConfig()"
+             ng-if="config.panel.parameterPanelOpened"
+             class="btn btn-default" style="padding: 2px 5px 2px 5px;">
+          <i class="fa fa-trash-o" aria-hidden="true"></i>
+        </div>
+        <div type="button" ng-if="config.panel.parameterPanelOpened"
+             ng-click="toggleParameterPanel()"
+             class="btn btn-default" style="padding: 2px 5px 2px 5px;">
+          <i class="fa fa-minus" style="font-size: 12px;" aria-hidden="true"></i>
+        </div>
+        <div type="button" ng-if="!config.panel.parameterPanelOpened"
+             ng-click="toggleParameterPanel()"
+             class="btn btn-default" style="padding: 2px 5px 2px 5px;">
+          <i class="fa fa-expand" style="font-size: 11px;" aria-hidden="true"></i>
+        </div>
+      </div>
+    </span>
+    <div style="clear: both;"></div> <!-- to fix previous span which has float: right -->
+  </div>
+  <div class="panel-body"
+       ng-if="config.panel.parameterPanelOpened"
+       style="padding-top: 13px; padding-bottom: 13px; height: 400px; overflow: auto;">
+    <table class="table table-striped">
+      <tr>
+        <th style="font-size: 12px; font-style: italic">Name</th>
+        <th style="font-size: 12px; font-style: italic">Type</th>
+        <th style="font-size: 12px; font-style: italic">Description</th>
+        <th style="font-size: 12px; font-style: italic">Value</th>
+      </tr>
+      <tr>
+      </tr>
+
+      <tr data-ng-repeat="paramSpec in config.paramSpecs[config.chart.current]">
+        <td style="font-weight: 400; vertical-align: middle;">{{paramSpec.name}}</td>
+        <td style="font-weight: 400; vertical-align: middle;">{{paramSpec.valueType}}</td>
+        <td ng-bind-html="paramSpec.description"
+          style="font-weight: 400; vertical-align: middle;"></td>
+        <td>
+          <div ng-if="isInputWidget(paramSpec)"
+               class="input-group">
+            <input type="text" class="form-control input-sm"
+                   style="font-weight: 400; font-size: 12px; vertical-align:middle; border-radius: 5px;"
+                   ng-keydown="parameterOnKeyDown($event, paramSpec)"
+                   data-ng-model="config.parameter[config.chart.current][paramSpec.name]" />
+          </div>
+          <div class="btn-group"
+               ng-if="isOptionWidget(paramSpec)">
+            <select class="form-control input-sm"
+                    ng-keydown="parameterOnKeyDown($event, paramSpec)"
+                    ng-change="parameterChanged(paramSpec)"
+                    data-ng-model="config.parameter[config.chart.current][paramSpec.name]"
+                    data-ng-options="optionValue for optionValue in paramSpec.optionValues"
+                    style="font-weight: 400; font-size: 12px;">
+            </select>
+          </div>
+
+          <div ng-if="isCheckboxWidget(paramSpec)">
+            <input type="checkbox"
+                   ng-keydown="parameterOnKeyDown($event, paramSpec)"
+                   ng-click="parameterChanged(paramSpec)"
+                   data-ng-model="config.parameter[config.chart.current][paramSpec.name]"
+                   data-ng-checked="config.parameter[config.chart.current][paramSpec.name]" />
+          </div>
+
+          <div ng-if="isTextareaWidget(paramSpec)">
+            <textarea class="form-control" rows="3"
+                      ng-keydown="parameterOnKeyDown($event, paramSpec)"
+                      data-ng-model="config.parameter[config.chart.current][paramSpec.name]"
+                      style="font-weight: 400; font-size: 12px;">
+            </textarea>
+          </div>
+
+        </td>
+      </tr>
+    </table>
+  </div>
+
+</div>