apache/echarts

Is there a better way of handling data groupings (Hierarchical Data) and centering their category labels on xAxis

Opened this issue · 11 comments

Version

4.9.0

Reproduction link

https://codepen.io/ChrisMash/pen/ExgXZLv

Steps to reproduce

Centering labels on second and third xAxis cannot be done in an intuitive manner when the number of items in each hierarchy or grouping is not even. The idea of hierarchies and grouped data (as to my understanding) is not fully supported?

What is expected?

What Im trying to achieve here is to produce a simple bar chart that has 2 series and 3 hierarchies. The code example i provided has this already implemented. The issue though (and what is expected) is a way to properly center the values on the second and third xAxis without having to add or remove empty strings in my xAxis data array.

What is actually happening?

The labels for second and third axes are always to the left since i have to provide empty strings always for my axes to work properly.


I used this issue to better understand how to use hierarchical data on a bar chart, please let me know if there is a better way to generate charts from such data.

#4902

Here is the example data i used and the expected result in excel.

Expected Result in Excel

Thank you very much for this great library 👌

Hi! We've received your issue and please be patient to get responded. 🎉
The average response time is expected to be within one day for weekdays.

In the meanwhile, please make sure that you have posted enough image to demo your request. You may also check out the API and chart option to get the answer.

If you don't get helped for a long time (over a week) or have an urgent question to ask, you may also send an email to dev@echarts.apache.org. Please attach the issue link if it's a technical question.

If you are interested in the project, you may also subscribe our mailing list.

Have a nice day! 🍵

An example to workaround.

option = {
  	color: ['#3e6591', '#eb7d22', '#d73f45'],
  	grid: {
      	left: 250
    },
    xAxis: {
      	axisLine: {
          	lineStyle: {color: '#ccc'}
        },
      	axisLabel: {
          	textStyle: {color: '#777'}
        }
    },
    yAxis: [{
      	inverse: true,
      	splitLine: {
          	show: true
        },
      	axisTick: {
          	length: 100,
          	lineStyle: {color: '#ccc'}          
        },
      	axisLine: {
          	lineStyle: {color: '#ccc'}
        },
        data: ['-', '-', '-', '-', '-']      
    }, {
      	name: '                     所属行业',
      	nameLocation: 'start',      
      	nameTextStyle: {
          	fontWeight: 'bold'
        },
	    position: 'left',
      	offset: 130,
      	axisLine: {
          	onZero: false,
          	show: false          
        },
      	axisTick: {
          	length: 70,
          	inside: true,
          	lineStyle: {color: '#ccc'}
        },      
      	axisLabel: {
          	inside: true
        },      
      	inverse: true,      
      	data: ['电商', '游戏', '金融', '旅游', '直播']
    }, {
      	name: '                产品名',
      	nameLocation: 'start',
      	nameTextStyle: {
          	fontWeight: 'bold'
        },      
		position: 'left',
      	offset: 220,
      	axisLine: {
          	onZero: false,
          	show: false
        },
      	axisTick: {
          	length: 100,
          	inside: true,
          	lineStyle: {color: '#ccc'}          
        },
      	axisLabel: {
          	inside: true
        },
      	inverse: true,
      	data: ['APP数据分析', 'DMP', '企业版', '移动广告鉴别', '']      
    }],
    series: [{
        type: 'bar',
        data:[220, 182, 191, 234, 290],
        label: {
         	normal: {
              	show: true,
              	position: 'left',
              	textStyle: {color: '#000'},
              	formatter: '合同金额',              
            }
        }
    }, {
        type: 'bar',
        data:[210, 132, 91, 204, 220],
        label: {
         	normal: {
              	show: true,
              	position: 'left',
              	textStyle: {color: '#000'},
              	formatter: '已收款',              
            }
        }      
    }, {
        type: 'bar',
        data:[120, 132, 131, 254, 278],
        label: {
         	normal: {
              	show: true,
              	position: 'left',
              	textStyle: {color: '#000'},
              	formatter: '应收款',              
            }
        }      
    }, {      
        type: 'bar',
        data:['-', '-', '-', '-', '-'],
      	yAxisIndex: 1
    }, {
        type: 'bar',
        data:['-', '-', '-', '-', '-'],
      	yAxisIndex: 2
    }]
};

image

Hello, I have question regarding this type of chart. If there is no data for all bars, then the chart still allocated space for the bar which leaves a gap. See the image below:
image

Is there any way to prevent creating this gap?

@mortenamby where you able to figure something out?

I guess you want to achieve something like this:

image

It would be great if we could have grouppings with different lengths.

@pissang any idea if this is possible right now?

I was able to imporve it:
image

I basically do the same as #4902 (comment) but when I use a dummy separator to indicate the sections and then I put the caption in the bar in the middle. This means that for pair groups it will still be a bit off (but at least it won't be in the first columns) and that for even groups it will be placed perfectly in the middle. To give you an idea:

xAxis: [
...,
{
  axisLabel: {
          inside: true,
          interval: (index, value) => {
            return value !== "" && value !== dummySeparator;
          },
        },
        offset: 80,
        axisTick: {
          length: 70,
          lineStyle: {
            color: "#CCC"
          },
          inside: true,
          interval: (index, value) => {
            return value === dummySeparator;
          }
        },
        splitArea: {
          show: true,
          interval: (index, value) => {
            return value === dummySeparator;
          }
        },
        data: groupCategrories.flatMap(c => {

          const spaces = new Array(c.length).fill("");
          spaces[0] = dummySeparator;
          spaces[Math.max(0, Math.trunc((spaces.length-1) / 2))] = c.caption; 
          return spaces;  
        })
}
}

@DavidMarquezF
I didn't manage to find a solution myself. I worked around it by choosing a different chart design, so it is not really relevant to me anymore. It may still be relevant to others as I think the gap is an issue if you have many empty bars that will never get data, which was the issue I was facing.

But nice to see that you found a way to do it :D

I was able to imporve it: image

I basically do the same as #4902 (comment) but when I use a dummy separator to indicate the sections and then I put the caption in the bar in the middle. This means that for pair groups it will still be a bit off (but at least it won't be in the first columns) and that for even groups it will be placed perfectly in the middle. To give you an idea:

xAxis: [
...,
{
  axisLabel: {
          inside: true,
          interval: (index, value) => {
            return value !== "" && value !== dummySeparator;
          },
        },
        offset: 80,
        axisTick: {
          length: 70,
          lineStyle: {
            color: "#CCC"
          },
          inside: true,
          interval: (index, value) => {
            return value === dummySeparator;
          }
        },
        splitArea: {
          show: true,
          interval: (index, value) => {
            return value === dummySeparator;
          }
        },
        data: groupCategrories.flatMap(c => {

          const spaces = new Array(c.length).fill("");
          spaces[0] = dummySeparator;
          spaces[Math.max(0, Math.trunc((spaces.length-1) / 2))] = c.caption; 
          return spaces;  
        })
}
}

Could you help to share the full code? Thanks.

In my project I build the config dynamically so I don't have a single config for all available right now. However, the only important part you need is what i posted (the xAxis). The rest should be quite easy to lay down.

I actually improved the design a bit more so that the labels from the second groups are offset enough so that they don't overlap with the first row of categories. I will share my code but it's not copy paste material, you will need to figure it out a bit.

let groupCategrories: { caption: string, length: number }[] = // Here you should put the ordered list of the second level of categories. Caption is the name it should have and length is how many subcategories it holds;
 const labels: (string | number)[] = //Here put the list of labels from the first row of labels

 const longestLabel: string = labels.reduce<string>((a: string, b): string => {
        const strB = b?.toString();
        return a.length > strB?.length ? a : strB;
   }, "") ?? "";

// We want to calculate the longest label in order to offset the second row of labels 
const maxPixelLengthStr = getTextWidth(longestLabel, DEFAULT_FONT);
const dummySeparator = "***";

const xAxis: XAXisOption [] = [{
      name: this.xLabel,
      nameLocation: "middle",
      type: "category",
      axisLine: {
          show: false
       },
       axisTick: {
          show: false
       },
      axisLabel: {
        interval: 0,
        rotate: 90,
      },
      splitArea: {
          show: false
        },
    },
   {
        type: "category",
        position: "bottom",
        axisLine: {
          show: false,
          onZero: false
        },
        axisLabel: {
          inside: true,
          interval: (index, value) => {
            return value !== "" && value !== dummySeparator;
          },
        },
        offset: maxPixelLengthStr + 20,
        axisTick: {
          length: maxPixelLengthStr + 15,
          lineStyle: {
            color: "#CCC"
          },
          inside: true,
          interval: (index, value) => {
            return value === dummySeparator ||
              (value !== "" && groupCategrories.find(c => c.caption === value).length <= 2);
          }
        },
        splitArea: {
          show: true,
          interval: (index, value) => {
            return value === dummySeparator;
          }
        },
        data: groupCategrories.flatMap(c => {

          const spaces: string[] = new Array<string>(c.length).fill("");
          spaces[0] = dummySeparator;
          spaces[Math.max(0, Math.trunc((spaces.length - 1) / 2))] = c.caption;
          return spaces;
        })
      }
];
   

//The getTextWidth helper function
function getTextWidth(text, font): number {
    // re-use canvas object for better performance
    const canvas = (getTextWidth as any).canvas || ((getTextWidth as any).canvas = document.createElement("canvas"));
    const context = canvas.getContext("2d");
    context.font = font;
    const metrics = context.measureText(text);
    return metrics.width;
  }
const DEFAULT_FONT = "400 14px \"Inter var\", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"";

Then, for the rest of the config, just build a normal bar chart with a single series. You just use a single series and then the axis config is actually what does all of the work.

I'm sorry I can't share a full copy-paste example, I just don't have the time to do that right now. However, with this info it should be quite straight forward since the only complicated part about this is the xAxis itself and in the snippet I've shared that should work right of the bat.

Any chance that this will be officially supported? Tibco's Spotfire has a nice hierarchical axis representation that would be great to see in e-charts someday, even if it weren't interactive like the following example.

image

B3Kay commented

I would love it if this could be officially supported! 🗡️
I'm looking for a solution to this in Grafana.

ysk3a commented

I also would love for an official support for this.

I've been playing around in the meantime with some of the work around in mentioned in some related issues but i've noticed a weird behavior when adding the datazoom or range slider to it.

https://stackblitz.com/edit/web-platform-j735xj?file=script.js

I was just testing out a code from an older issue which has uneven subcategories (some having 1month or 3 or 2months) but in that example, with the datazoom, a weird and unwanted behavior happens when user change the left datazoom range toward the right, the group category axis tick shifts giving the user the impression that 2016winter is including 10, 11 and 12.

group category red axis line showing correctly:
image

after changing range: (the red line of 2016winter/2016冬 jumps to group 10, 11, 12 instead of only 12)
image

I think this happens due to uneven datazoom range since it also happens in the middle range as well. i'm not exactly sure as this is a quick guess.

I'm not sure if this is a bug or because the interval property in axistick needs to be more complex?