feat: generate bar race chart from table
diff --git a/index.html b/index.html
index de4a318..725bacc 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,7 @@
         <link rel="icon" href="/favicon.ico" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
         <title>ECharts WWW SPA Boilerplate</title>
+        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.css">
         <style>
             body {
                 padding: 0;
diff --git a/package.json b/package.json
index 9d4a02a..bb3f5b7 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
         "element-plus": "^1.0.2-beta.44",
         "handsontable": "^6.2.2",
         "jquery": "^3.6.0",
-        "lodash": "^4.17.19",
+        "lodash": "^4.17.21",
         "vue": "^3.0.11",
         "vue-i18n": "^9.1.6"
     }
diff --git a/src/App.vue b/src/App.vue
index e593a7c..77b2d87 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,17 +1,14 @@
 <template>
 <el-container>
-    <!-- <el-aside>
-        <h3>Side Nav</h3>
-    </el-aside> -->
     <el-main>
-        <BTable/>
+        <BBody/>
     </el-main>
 </el-container>
 </template>
 
 <script lang='ts' setup>
 
-import BTable from './components/BTable.vue';
+import BBody from './components/BBody.vue';
 
 </script>
 
diff --git a/src/components/BBody.vue b/src/components/BBody.vue
new file mode 100644
index 0000000..498a897
--- /dev/null
+++ b/src/components/BBody.vue
@@ -0,0 +1,103 @@
+<template>
+    <div class="w-full h-full">
+        <div class="grid grid-cols-12 h-full text-sm">
+            <el-card class="box-card col-span-3">
+                <h1 slot="header" class="clearfix text-xl">
+                    动态排序柱状图生成器
+                </h1>
+                <div id="el-config" class="align-middle">
+                    <!-- <div class="my-3">
+                        <el-button onclick="run()" size="medium">运行</el-button>
+                        <el-button size="medium">导出</el-button>
+                    </div> -->
+                    <el-form ref="form">
+                        <div class="grid grid-cols-3 form-row">
+                            <label class="col-span-1">标题</label>
+                            <el-input
+                                id="input-title"
+                                value="汽车产量动态排名"
+                                size="medium"
+                                class="col-span-2"
+                                v-model="title"
+                            >
+                            </el-input>
+                        </div>
+                        <div class="grid grid-cols-3 form-row">
+                            <label class="col-span-1">显示排名上限</label>
+                            <el-input
+                                id="input-max"
+                                type="number"
+                                value="10"
+                                size="medium"
+                                class="col-span-2"
+                                v-model="maxDataCnt"
+                            >
+                            </el-input>
+                        </div>
+                    </el-form>
+                </div>
+            </el-card>
+            <el-card
+                class="box-card col-span-4 relative"
+                body-style="height: 100%"
+            >
+                <BTable
+                    @after-change="tableAfterChange"
+                />
+            </el-card>
+            <el-card
+                class="box-card col-span-5 relative"
+                body-style="height: 100%"
+            >
+                <BChart
+                    :title="title"
+                    :chartData="chartData"
+                />
+            </el-card>
+        </div>
+    </div>
+</template>
+
+<script lang="ts">
+import {defineComponent} from 'vue';
+import BTable, {ChartData} from './BTable.vue';
+import BChart from './BChart.vue';
+
+export default defineComponent({
+    name: 'BBody',
+    data() {
+        return {
+            title: '汽车销量',
+            maxDataCnt: 10,
+            chartData: null
+        }
+    },
+    components: {
+        BTable,
+        BChart
+    },
+    mounted: () => {
+    },
+    methods: {
+        tableAfterChange(data: ChartData) {
+            this.chartData = data;
+        }
+    }
+})
+</script>
+
+<style scoped>
+@layer utilities {
+    .form-row {
+        @apply my-3;
+
+        label {
+            @apply py-1;
+        }
+    }
+
+    .box-card {
+        @apply m-1;
+    }
+}
+</style>
diff --git a/src/components/BChart.vue b/src/components/BChart.vue
new file mode 100644
index 0000000..97dc9db
--- /dev/null
+++ b/src/components/BChart.vue
@@ -0,0 +1,130 @@
+<template>
+    <div>
+        <div slot="header" class="clearfix text-base">
+            预览{{title}}
+            <a href="#">
+                <i class="el-icon-refresh"></i>
+            </a>
+        </div>
+        <div
+            id="bar-race-preview"
+            ref="chart"
+            class="absolute bottom-4 top-14 left-5 right-5 border">
+        </div>
+    </div>
+</template>
+
+<script lang="ts">
+import {defineComponent} from 'vue';
+import * as echarts from 'echarts';
+
+const headerLength = 2;
+let chart: echarts.ECharts;
+
+export default defineComponent({
+    name: 'BChart',
+    props: {
+        title: String,
+        chartData: Array
+    },
+    data() {
+        return {
+            timeoutHandlers: []
+        };
+    },
+    watch: {
+        chartData() {
+            this.run();
+        }
+    },
+    mounted() {
+        chart = echarts.init(this.$refs.chart as HTMLElement);
+    },
+    methods: {
+        run() {
+            if (!chart) {
+                return;
+            }
+            const option = {
+                dataset: {
+                    source: this.chartData
+                },
+                xAxis: {
+                    type: 'value'
+                },
+                yAxis: {
+                    type: 'category',
+                    inverse: true,
+                    animationDuration: 300,
+                    animationDurationUpdate: 300
+                },
+                series: [{
+                    id: 'bar',
+                    type: 'bar',
+                    encode: {
+                        x: 2
+                    },
+                    seriesLayoutBy: 'row',
+                    realtimeSort: true,
+                    label: {
+                        show: true,
+                        position: 'right'
+                    }
+                }],
+                grid: {
+                    right: 60
+                },
+                animationDurationUpdate: 5000,
+                animationEasing: 'linear',
+                animationEasingUpdate: 'linear'
+            };
+            chart.setOption(option as echarts.EChartsOption, true);
+
+            const dataCnt = this.chartData.length - headerLength - 1;
+            const that = this;
+            for (let i = 0; i < dataCnt; ++i) {
+                (function (i: number) {
+                    let timeout: number;
+                    const timeoutCb = function () {
+                        chart.setOption({
+                            // title: [{
+                            //     text: getDataName(i)
+                            // }],
+                            series: [{
+                                type: 'bar',
+                                id: 'bar',
+                                encode: {
+                                    x: i + headerLength + 1
+                                }
+                            }]
+                        });
+                        that.removeTimeoutHandlers(timeout);
+                    };
+                    timeout = window.setTimeout(timeoutCb, i * 5000);
+                    that.timeoutHandlers.push(timeout);
+                })(i);
+            }
+        },
+
+        clearTimeoutHandlers() {
+            for (let i = 0; i < this.timeoutHandlers.length; ++i) {
+                clearTimeout(this.timeoutHandlers[i]);
+                this.timeoutHandlers.splice(i, 1);
+            }
+        },
+
+        removeTimeoutHandlers(handler: number) {
+            for (let i = 0; i < this.timeoutHandlers.length; ++i) {
+                if (this.timeoutHandlers[i] === handler) {
+                    this.timeoutHandlers.splice(i, 1);
+                }
+            }
+        }
+    }
+})
+</script>
+
+<style scoped>
+@layer utilities {
+}
+</style>
diff --git a/src/components/BTable.vue b/src/components/BTable.vue
index 9f6fc36..34dc0a3 100644
--- a/src/components/BTable.vue
+++ b/src/components/BTable.vue
@@ -1,74 +1,176 @@
 <template>
-    <div class="w-full h-full">
-        <div class="grid grid-cols-12 h-full text-sm">
-            <el-card class="box-card col-span-3">
-                <h1 slot="header" class="clearfix text-xl">
-                    Apache ECharts Bar-Race 生成器
-                </h1>
-                <div id="el-config" class="align-middle">
-                    <!-- <div class="my-3">
-                        <el-button onclick="run()" size="medium">运行</el-button>
-                        <el-button size="medium">导出</el-button>
-                    </div> -->
-                    <el-form ref="form">
-                        <div class="grid grid-cols-3 form-row">
-                            <label class="col-span-1">标题</label>
-                            <el-input id="input-title" value="汽车产量动态排名" size="medium" class="col-span-2"></el-input>
-                        </div>
-                        <div class="grid grid-cols-3 form-row">
-                            <label class="col-span-1">显示排名上限</label>
-                            <el-input id="input-max" type="number" value="10" size="medium" class="col-span-2"></el-input>
-                        </div>
-                    </el-form>
-                </div>
-            </el-card>
-            <el-card class="box-card col-span-4 relative" body-style="height: 100%">
-                <div slot="header" class="clearfix text-base">
-                    数据
-                </div>
-                <div id="table-panel" class="overflow-auto absolute bottom-4 top-14 left-5 right-5 border">
-                </div>
-            </el-card>
-            <el-card class="box-card col-span-5 relative" body-style="height: 100%">
-                <div slot="header" class="clearfix text-base">
-                    预览
-                    <a href="#">
-                        <i class="el-icon-refresh"></i>
-                    </a>
-                </div>
-                <div id="bar-race-preview" class="absolute bottom-4 top-14 left-5 right-5 border">
-                </div>
-            </el-card>
+    <div>
+        <div slot='header' class='clearfix text-base'>
+            数据
+        </div>
+        <div ref='table' id='table-panel' class='overflow-auto absolute bottom-4 top-14 left-5 right-5 border'>
         </div>
     </div>
 </template>
 
-<script lang="ts">
+<script lang='ts'>
+import Handsontable from 'handsontable';
 import {defineComponent} from 'vue';
-import btable from './btable';
+import * as _ from 'lodash';
+
+const headerLength = 2;
+export type ChartData = string[][];
 
 export default defineComponent({
     name: 'BTable',
     props: {
     },
-    mounted: () => {
-        btable.initTable();
+    data() {
+        return {
+            tableData: [
+                ['Name', 'Ford', 'Tesla', 'Toyota', 'Honda'],
+                ['Color', '', '', '', ''],
+                ['2017', '10', '11', '12', '13'],
+                ['2018', '20', '11', '14', '13'],
+                ['2019', '30', '15', '12', '13']
+            ],
+            table: null,
+            debouncedTableChange: null
+        }
+    },
+    mounted() {
+        this.insertEmptyCells();
+        this.table = new Handsontable(this.$refs.table as Element, {
+            data: this.tableData,
+            rowHeaders: true,
+            colHeaders: true,
+            filters: true,
+            dropdownMenu: true,
+            // cell: [{
+            //     row: 0,
+            //     col: 0,
+            //     readOnly: true
+            // }, {
+            //     row: 1,
+            //     col: 0,
+            //     readOnly: true
+            // }],
+            //- cells: function (row, col) {
+            //-     if (row === 1) {
+            //-         return {
+            //-             renderer: colorRenderer
+            //-         }
+            //-     }
+            //-     else {
+            //-         return {};
+            //-     }
+            //- }
+        });
+        this.table.updateSettings({
+            afterChange: () => {
+                console.log('after')
+                this.debouncedTableChange();
+            }
+        });
+
+        this.debouncedTableChange = _.debounce(() => {
+            this.$emit('afterChange', this.getChartData());
+        }, 500);
+
+        this.$emit('afterChange', this.getChartData());
+    },
+    unmounted() {
+        this.debouncedTableChange.cancel();
+    },
+    methods: {
+        getChartData(): ChartData {
+            let columns = 0;
+            const firstRow = this.tableData[0];
+            for (let i = 0; i < firstRow.length; ++i) {
+                if (!firstRow[i] || !firstRow[i].trim()) {
+                    columns = i;
+                    break;
+                }
+            }
+
+            let rows = 0;
+            for (let i = 0; i < this.tableData.length; ++i) {
+                if (!this.tableData[i] || !this.tableData[i][0] || !this.tableData[i][0]) {
+                    rows = i;
+                    break;
+                }
+            }
+
+            return this.tableData.slice(0, rows)
+                .map(row => row.slice(0, columns));
+        },
+
+        insertEmptyCells() {
+            for (let i = 0; i < this.tableData.length; ++i) {
+                for (let j = this.tableData[i].length; j < 50; ++j) {
+                    this.tableData[i].push('');
+                }
+            }
+            for (let i = this.tableData.length; i < 100; ++i) {
+                const row = [];
+                for (let j = 0; j < 50; ++j) {
+                    row.push('');
+                }
+                this.tableData.push(row);
+            }
+        },
+
+        trimColumns(rowData: (string | number)[]) {
+            for (let i = rowData.length - 1; i > 0; --i) {
+                if (rowData[i] && rowData[i] !== '') {
+                    return rowData.slice(1, i + 1);
+                }
+            }
+            return [];
+        },
+
+        trimRows() {
+            const data = this.tableData;
+            if (data.length <= headerLength) {
+                return [];
+            }
+            for (let i = data.length - 1; i >= headerLength; --i) {
+                let isEmpty = true;
+                for (let j = 1; j < data[i].length; ++j) {
+                    if (data[i][j] && data[i][j] !== '') {
+                        isEmpty = false;
+                        break;
+                    }
+                }
+                if (!isEmpty) {
+                    return data.slice(headerLength, i + 1);
+                }
+            }
+            return [];
+        },
+
+        getYData() {
+            if (this.tableData.length <= headerLength) {
+                return [];
+            }
+            return this.trimColumns(this.tableData[0]);
+        },
+
+        getSeriesData(id: number) {
+            if (this.tableData.length <= id + headerLength) {
+                return [];
+            }
+            return this.trimColumns(this.tableData[id + headerLength]);
+        },
+
+        getDataName(id: number) {
+            if (this.tableData.length <= id + headerLength) {
+                return '';
+            }
+            else {
+                return this.tableData[id + headerLength][0];
+            }
+        }
     }
 })
 </script>
 
 <style scoped>
 @layer utilities {
-    .form-row {
-        @apply my-3;
-
-        label {
-            @apply py-1;
-        }
-    }
-
-    .box-card {
-        @apply m-1;
-    }
 }
 </style>