diff --git a/src/coord/axisTickLabelBuilder.ts b/src/coord/axisTickLabelBuilder.ts index 904dcd894b..a7e4685561 100644 --- a/src/coord/axisTickLabelBuilder.ts +++ b/src/coord/axisTickLabelBuilder.ts @@ -395,6 +395,19 @@ export function calculateCategoryInterval(axis: Axis, ctx: AxisLabelsComputingCo width = rect.width * 1.3; height = rect.height * 1.3; + // If the label content is wrapped or truncated into the fixed + // `axisLabel.width` (see `overflow: 'break' | 'truncate'`), the + // room a label occupies along the axis is bounded by that width + // rather than by its unwrapped content width; otherwise the + // interval is overestimated and labels are dropped even though + // they would not overlap. Long labels rotated to be (nearly) + // vertical are not affected, since their occupied width is + // governed by the content height instead. + const labelWidth = params.labelWidth; + if (labelWidth != null && labelWidth > 0 && Math.abs(Math.cos(rotation)) > 0.1) { + width = Math.min(width, labelWidth * 1.3); + } + // Min size, void long loop. maxW = Math.max(maxW, width, 7); maxH = Math.max(maxH, height, 7); @@ -465,6 +478,15 @@ function calculateCategoryIntervalDealCache( function fetchAutoCategoryIntervalCalculationParams(axis: Axis) { const labelModel = axis.getLabelModel(); + // `width` only takes effect together with `overflow: 'break' | 'truncate'` + // (see `createTextStyle` in labelStyle); in other cases the label is + // laid out at its natural content width. + const overflow = labelModel.get('overflow'); + const optionWidth = labelModel.get('width'); + const labelWidth = (overflow === 'break' || overflow === 'truncate') + && zrUtil.isNumber(optionWidth) + ? optionWidth as number + : null; return { axisRotate: axis.getRotate ? axis.getRotate() @@ -472,7 +494,8 @@ function fetchAutoCategoryIntervalCalculationParams(axis: Axis) { ? 90 : 0, labelRotate: labelModel.get('rotate') || 0, - font: labelModel.getFont() + font: labelModel.getFont(), + labelWidth: labelWidth }; } diff --git a/test/ut/spec/component/axis/axisLabel.test.ts b/test/ut/spec/component/axis/axisLabel.test.ts new file mode 100644 index 0000000000..9d37d0f1c8 --- /dev/null +++ b/test/ut/spec/component/axis/axisLabel.test.ts @@ -0,0 +1,169 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you 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 { platformApi, setPlatformAPI } from 'zrender/src/core/platform'; + +// The unit test environment (jest-canvas-mock) measures every character +// as 1px wide, which makes the auto category interval calculation +// meaningless: labels that overlap in a real browser would never be +// detected. Install a deterministic, browser-like `measureText` +// (CJK glyphs are full-width, latin glyphs about half-width, at the +// default 12px font) before any chart is created, so that the label +// layout behaves like it does in a browser. +const origCreateCanvas = platformApi.createCanvas; +const origLoadImage = platformApi.loadImage; +const FONT_SIZE = 12; +function charWidth(ch: string): number { + const code = ch.charCodeAt(0); + if (code >= 0x2E80) { // CJK and other wide glyphs: full width + return FONT_SIZE; + } + if (ch >= 'A' && ch <= 'Z') { + return FONT_SIZE * 0.71; + } + return FONT_SIZE * 0.56; +} +setPlatformAPI({ + measureText(text: string) { + let width = 0; + for (let i = 0; i < text.length; i++) { + width += charWidth(text[i]); + } + return {width}; + }, + createCanvas() { + return origCreateCanvas(); + }, + loadImage(src, onload, onerror) { + return origLoadImage(src, onload, onerror); + } +}); + +import { createChart, getViewGroup } from '../../../core/utHelper'; +import { EChartsType } from '../../../../../src/echarts'; +import { EChartsOption } from '../../../../../src/export/option'; + + +// The option from https://github.com/apache/echarts/issues/21194 : +// with `axisLabel.width: 50` and `overflow: 'break'` the labels wrap +// into the configured width, so they do not overlap and every label +// should be displayed even though the chart (grid) width is limited. +function makeIssueOption(): EChartsOption { + return { + legend: {show: true}, + grid: {width: 300, top: '10%', left: 0, bottom: 0, containLabel: true}, + xAxis: { + type: 'category', + data: ['XX当日发电量', 'YY当日发电量', 'ZZ当日发电量'], + axisLabel: {width: 50, overflow: 'break', hideOverlap: false} + }, + yAxis: { + type: 'value', + splitLine: {show: true, lineStyle: {width: 1, color: 'rgba(128,128,128,0.5)'}} + }, + series: [{type: 'bar', data: [1417.69, 1372.82, 1254.72]}] + } as EChartsOption; +} + +function getVisibleXAxisLabels(chart: EChartsType): string[] { + const group = getViewGroup(chart, 'xAxis', 0); + const labels: string[] = []; + group.traverse(function (el) { + const anyEl = el as unknown as { + type: string, + ignore?: boolean, + style?: {text?: string}, + getTextContent?: () => {style: {text: string}, ignore?: boolean} + }; + // A label is rendered either as a standalone text element or as + // the text content attached to another element. + let text: string; + let ignored = !!anyEl.ignore; + if (anyEl.type === 'text' && anyEl.style && anyEl.style.text != null) { + text = anyEl.style.text; + } + else if (anyEl.getTextContent) { + const textContent = anyEl.getTextContent(); + if (textContent) { + text = textContent.style.text; + ignored = ignored || !!textContent.ignore; + } + } + if (text != null && !ignored) { + // Wrapped labels contain line breaks; compare them flattened. + labels.push(text.replace(/\n/g, '')); + } + }); + return labels; +} + +describe('axis_label_layout', function () { + + let chart: EChartsType; + afterEach(function () { + if (chart) { + chart.dispose(); + chart = null; + } + }); + + it('labels wrapped by axisLabel.width and overflow break are not dropped by auto interval', function () { + chart = createChart({width: 900, height: 800}); + chart.setOption(makeIssueOption()); + expect(getVisibleXAxisLabels(chart)).toEqual([ + 'XX当日发电量', 'YY当日发电量', 'ZZ当日发电量' + ]); + }); + + it('labels truncated by axisLabel.width and overflow truncate are not dropped by auto interval', function () { + chart = createChart({width: 900, height: 800}); + const option = makeIssueOption(); + (option.xAxis as {axisLabel: object}).axisLabel = {width: 50, overflow: 'truncate', hideOverlap: false}; + chart.setOption(option); + expect(getVisibleXAxisLabels(chart)).toEqual([ + 'XX当日发电量', 'YY当日发电量', 'ZZ当日发电量' + ]); + }); + + it('width without overflow break or truncate does not disable auto interval', function () { + chart = createChart({width: 900, height: 800}); + const option = makeIssueOption(); + // `width` alone has no effect on the label layout, so the labels + // keep their natural width and the auto interval still applies. + (option.xAxis as {axisLabel: object}).axisLabel = {width: 50, hideOverlap: false}; + chart.setOption(option); + expect(getVisibleXAxisLabels(chart)).toEqual([ + 'XX当日发电量', 'ZZ当日发电量' + ]); + }); + + it('wide labels without width limit are still thinned by auto interval', function () { + chart = createChart({width: 900, height: 800}); + const option = makeIssueOption(); + (option.xAxis as {axisLabel: object}).axisLabel = {hideOverlap: false}; + chart.setOption(option); + // Sanity check of the measurement setup: without `width`/`overflow` + // the 89px wide labels do not fit into the ~86px bands, so the + // auto interval must keep dropping the middle label. + expect(getVisibleXAxisLabels(chart)).toEqual([ + 'XX当日发电量', 'ZZ当日发电量' + ]); + }); + +});