Skip to content

Commit

Permalink
line chart: skip axis label render based on visibility (#5317)
Browse files Browse the repository at this point in the history
This change improves line chart axis by measuring how big the label is
so a label does not overlap with another.

Instead of using DOM to measure the dimension, this uses 2D Canvas to
measure the text which is a lot performant as it would not have to cause
reflow.

Do note that we can be smarter with the tick filtering but this
iteration is quite dumb and only filters from the start.
  • Loading branch information
stephanwlee authored Sep 17, 2021
1 parent 47a9c05 commit a060de9
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,58 @@ export function getTicksForLinearScale(

return {major: Array.from(majorTickValMap.values()), minor};
}

const canvasForMeasure = document.createElement('canvas').getContext('2d');

/**
* Filters minor ticks by their position and dimensions so each label does not
* get overlapped with another.
* @param minorTicks Minor ticks to be filtered.
* @param getDomPos A function that returns position of a tick in a DOM.
* @param axis Whether tick is for 'x' or 'y' axis.
* @param axisFont Font used for the axis label.
* @param marginBetweenAxis Optional required spacing between labels.
* @returns Filtered minor ticks based on their visibilities.
*/
export function filterTicksByVisibility(
minorTicks: MinorTick[],
getDomPos: (tick: MinorTick) => number,
axis: 'x' | 'y',
axisFont: string,
marginBetweenAxis = 5
): MinorTick[] {
if (!minorTicks.length || !canvasForMeasure) return minorTicks;
// While tick is in data coordinate system, DOM is on the opposite system;
// while pixels go from top=0 to down, data goes from bottom=0 to up.
const coordinateUnit = axis === 'x' ? 1 : -1;

let currentMax: number | null = null;
return minorTicks.filter((tick) => {
const position = getDomPos(tick);
canvasForMeasure.font = axisFont;
const textMetrics = canvasForMeasure.measureText(tick.tickFormattedString);
const textDim =
axis === 'x'
? textMetrics.width
: textMetrics.actualBoundingBoxAscent -
textMetrics.actualBoundingBoxDescent;

if (currentMax === null) {
if (position + coordinateUnit * textDim < 0) {
return false;
}
currentMax = position + coordinateUnit * textDim;
return true;
}

if (
coordinateUnit *
(currentMax + coordinateUnit * marginBetweenAxis - position) >
0
) {
return false;
}
currentMax = position + coordinateUnit * textDim;
return true;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ limitations under the License.

import {createScale, LinearScale, ScaleType, TemporalScale} from '../lib/scale';
import {
filterTicksByVisibility,
getStandardTicks,
getTicksForLinearScale,
getTicksForTemporalScale,
Expand Down Expand Up @@ -369,4 +370,111 @@ describe('line_chart_v2/sub_view/axis_utils test', () => {
});
});
});

describe('#filterTicksByVisibility', () => {
// 10px monospace has about below dimensions.
const CHAR_HEIGHT = 9;
const CHAR_WIDTH = 6.021;

describe('x axis', () => {
it('filters ticks if it overlaps', () => {
const ticks = filterTicksByVisibility(
[
{value: 0, tickFormattedString: 'ABC'},
{value: 0, tickFormattedString: 'XYZ'},
{value: 18, tickFormattedString: 'A'},
{value: CHAR_WIDTH * 3, tickFormattedString: 'B'},
{value: CHAR_WIDTH * 5, tickFormattedString: 'C'},
],
(tick) => tick.value,
'x',
'10px monospace',
0
);

expect(ticks).toEqual([
{value: 0, tickFormattedString: 'ABC'},
{value: CHAR_WIDTH * 3, tickFormattedString: 'B'},
{value: CHAR_WIDTH * 5, tickFormattedString: 'C'},
]);
});

it('filters everything out of nothing is visible', () => {
const ticks = filterTicksByVisibility(
[
{value: -100, tickFormattedString: 'A'},
{value: -50, tickFormattedString: 'B'},
],
(tick) => tick.value,
'x',
'10px monospace',
0
);

expect(ticks).toEqual([]);
});

it('honors the padding', () => {
const ticks = filterTicksByVisibility(
[
{value: 0, tickFormattedString: 'ABC'},
{value: CHAR_WIDTH * 3, tickFormattedString: 'B'},
{value: CHAR_WIDTH * 3 + 10, tickFormattedString: 'C'},
],
(tick) => tick.value,
'x',
'10px monospace',
10
);

expect(ticks).toEqual([
{value: 0, tickFormattedString: 'ABC'},
{value: CHAR_WIDTH * 3 + 10, tickFormattedString: 'C'},
]);
});
});

describe('y axis', () => {
it('filters ticks if it overlaps', () => {
const ticks = filterTicksByVisibility(
[
{value: 200, tickFormattedString: 'A'},
{value: 200, tickFormattedString: 'B'},
{value: 195, tickFormattedString: 'C'},
{value: 200 - CHAR_HEIGHT, tickFormattedString: 'D'},
{value: 200 - CHAR_HEIGHT * 5, tickFormattedString: 'E'},
],
(tick) => tick.value,
'y',
'10px monospace',
0
);

expect(ticks).toEqual([
{value: 200, tickFormattedString: 'A'},
{value: 200 - CHAR_HEIGHT, tickFormattedString: 'D'},
{value: 200 - CHAR_HEIGHT * 5, tickFormattedString: 'E'},
]);
});

it('honors the padding', () => {
const ticks = filterTicksByVisibility(
[
{value: 200, tickFormattedString: 'A'},
{value: 200 - CHAR_HEIGHT, tickFormattedString: 'B'},
{value: 200 - CHAR_HEIGHT - 10, tickFormattedString: 'C'},
],
(tick) => tick.value,
'y',
'10px monospace',
10
);

expect(ticks).toEqual([
{value: 200, tickFormattedString: 'A'},
{value: 200 - CHAR_HEIGHT - 10, tickFormattedString: 'C'},
]);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*ngFor="let tick of minorTicks; trackBy: trackByMinorTick"
>
<text
[style.font]="axisFont"
[attr.x]="textXPosition(tick.value)"
[attr.y]="textYPosition(tick.value)"
>
Expand Down Expand Up @@ -52,10 +53,11 @@
*ngFor="let tick of majorTicks; index as i; last as isLast; trackBy: trackByMajorTick"
[class.major-label]="true"
[class.last]="isLast"
[style.left]="getMajorXPosition(tick) + 'px'"
[style.left.px]="getMajorXPosition(tick)"
[style.width]="getMajorWidthString(tick, isLast, majorTicks[i + 1])"
[style.bottom]="getMajorYPosition(tick) + 'px'"
[style.bottom.px]="getMajorYPosition(tick)"
[style.height]="getMajorHeightString(tick, isLast, majorTicks[i + 1])"
[style.font]="axisFont"
[title]="getFormatter().formatLong(tick.start)"
><span>{{ tick.tickFormattedString }}</span></span
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ import {
getScaleRangeFromDomDim,
} from './chart_view_utils';
import {
filterTicksByVisibility,
getStandardTicks,
getTicksForLinearScale,
getTicksForTemporalScale,
MajorTick,
MinorTick,
} from './line_chart_axis_utils';

const AXIS_FONT = '11px Roboto, sans-serif';

@Component({
selector: 'line-chart-axis',
templateUrl: 'line_chart_axis_view.ng.html',
Expand Down Expand Up @@ -95,7 +98,12 @@ export class LineChartAxisComponent {
}

this.majorTicks = ticks.major;
this.minorTicks = ticks.minor;
this.minorTicks = filterTicksByVisibility(
ticks.minor,
(tick) => this.getDomPos(tick.value),
this.axis,
AXIS_FONT
);
}

getFormatter(): Formatter {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {MatIconTestingModule} from '../../../testing/mat_icon_module';
import {Extent, Scale, ScaleType} from '../lib/public_types';
import {createScale} from '../lib/scale';
import {LineChartAxisComponent} from './line_chart_axis_view';
import * as utils from './line_chart_axis_utils';

@Component({
selector: 'testable-comp',
Expand Down Expand Up @@ -97,6 +98,8 @@ describe('line_chart_v2/sub_view/axis test', () => {
}).compileComponents();

overlayContainer = TestBed.inject(OverlayContainer);
// `filterTicksByVisibility` is tested separately.
spyOn(utils, 'filterTicksByVisibility').and.callFake((ticks) => ticks);
});

function assertLabels(debugElements: DebugElement[], axisLabels: string[]) {
Expand Down

0 comments on commit a060de9

Please sign in to comment.