diff --git a/src/components/react_canvas/axis.tsx b/src/components/react_canvas/axis.tsx index 585ccd6435c..e2ad6f1f701 100644 --- a/src/components/react_canvas/axis.tsx +++ b/src/components/react_canvas/axis.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { Group, Line, Rect, Text } from 'react-konva'; -import { AxisTick, AxisTicksDimensions, isHorizontal, isVertical } from '../../lib/axes/axis_utils'; +import { + AxisTick, AxisTicksDimensions, centerRotationOrigin, getHorizontalAxisTickLineProps, + getTickLabelProps, getVerticalAxisTickLineProps, isHorizontal, isVertical, +} from '../../lib/axes/axis_utils'; import { AxisSpec, Position } from '../../lib/series/specs'; import { Theme } from '../../lib/themes/theme'; import { Dimensions } from '../../lib/utils/dimensions'; @@ -12,6 +15,7 @@ interface AxisProps { axisPosition: Dimensions; ticks: AxisTick[]; debug: boolean; + chartDimensions: Dimensions; } export class Axis extends React.PureComponent { @@ -23,33 +27,37 @@ export class Axis extends React.PureComponent { axes: { tickFontFamily, tickFontSize, tickFontStyle }, } = this.props.chartTheme; const { - axisSpec: { tickSize, tickPadding, position }, - axisTicksDimensions: { maxTickHeight, maxTickWidth }, + axisSpec: { + tickSize, + tickPadding, + position, + }, + axisTicksDimensions, debug, } = this.props; + const tickLabelRotation = this.props.axisSpec.tickLabelRotation || 0; + + const tickLabelProps = getTickLabelProps( + tickLabelRotation, + tickSize, + tickPadding, + tick.position, + position, + axisTicksDimensions, + ); + + const { maxLabelTextWidth, maxLabelTextHeight } = axisTicksDimensions; + const centeredRectProps = centerRotationOrigin(axisTicksDimensions, { x: tickLabelProps.x, y: tickLabelProps.y }); + const textProps = { - x: 0, - y: 0, - align: 'center', - width: 0, - height: 0, - verticalAlign: 'middle', + width: maxLabelTextWidth, + height: maxLabelTextHeight, + rotation: tickLabelRotation, + ...tickLabelProps, + ...centeredRectProps, }; - if (isVertical(position)) { - textProps.y = tick.position - maxTickHeight / 2; - textProps.align = position === Position.Left ? 'right' : 'left'; - textProps.x = position === Position.Left ? -maxTickWidth : tickSize + tickPadding; - textProps.height = maxTickHeight; - textProps.width = maxTickWidth; - } else { - textProps.y = position === Position.Top ? 0 : tickSize + tickPadding; - textProps.x = tick.position - maxTickWidth / 2; - textProps.align = 'center'; - textProps.height = maxTickHeight; - textProps.width = maxTickWidth; - textProps.verticalAlign = position === Position.Top ? 'bottom' : 'top'; - } + return ( {debug && } @@ -68,22 +76,32 @@ export class Axis extends React.PureComponent { private renderTickLine = (tick: AxisTick, i: number) => { const { axisSpec: { tickSize, tickPadding, position }, - axisTicksDimensions: { maxTickHeight }, + axisTicksDimensions: { maxLabelBboxHeight }, + chartDimensions, + chartTheme: { chart: { paddings } }, } = this.props; - const lineProps = []; + const showGridLines = this.props.axisSpec.showGridLines || false; - if (isVertical(position)) { - lineProps[0] = position === Position.Left ? tickPadding : 0; - lineProps[1] = tick.position; - lineProps[2] = position === Position.Left ? tickSize + tickPadding : tickSize; - lineProps[3] = tick.position; - } else { - lineProps[0] = tick.position; - lineProps[1] = position === Position.Top ? maxTickHeight + tickPadding : 0; - lineProps[2] = tick.position; - lineProps[3] = position === Position.Top ? maxTickHeight + tickPadding + tickSize : tickSize; - } + const lineProps = isVertical(position) ? + getVerticalAxisTickLineProps( + showGridLines, + position, + tickPadding, + tickSize, + tick.position, + chartDimensions.width, + paddings, + ) : getHorizontalAxisTickLineProps( + showGridLines, + position, + tickPadding, + tickSize, + tick.position, + maxLabelBboxHeight, + chartDimensions.height, + paddings, + ); return ; } @@ -91,7 +109,7 @@ export class Axis extends React.PureComponent { const { ticks, axisPosition } = this.props; return ( - {this.renderLine()} + {this.renderAxisLine()} {ticks.map(this.renderTickLine)} {ticks.filter((tick) => tick.label !== null).map(this.renderTickLabel)} @@ -100,7 +118,7 @@ export class Axis extends React.PureComponent { ); } - private renderLine = () => { + private renderAxisLine = () => { const { axisSpec: { tickSize, tickPadding, position }, axisPosition, @@ -116,9 +134,9 @@ export class Axis extends React.PureComponent { lineProps[0] = 0; lineProps[2] = axisPosition.width; lineProps[1] = - position === Position.Top ? axisTicksDimensions.maxTickHeight + tickSize + tickPadding : 0; + position === Position.Top ? axisTicksDimensions.maxLabelBboxHeight + tickSize + tickPadding : 0; lineProps[3] = - position === Position.Top ? axisTicksDimensions.maxTickHeight + tickSize + tickPadding : 0; + position === Position.Top ? axisTicksDimensions.maxLabelBboxHeight + tickSize + tickPadding : 0; } return ; } @@ -138,7 +156,7 @@ export class Axis extends React.PureComponent { const { axisPosition: { height }, axisSpec: { title, position, tickSize, tickPadding }, - axisTicksDimensions: { maxTickWidth }, + axisTicksDimensions: { maxLabelBboxWidth }, chartTheme: { axes: { titleFontFamily, titleFontSize, titleFontStyle, titlePadding }, }, @@ -150,8 +168,8 @@ export class Axis extends React.PureComponent { const top = height; const left = position === Position.Left - ? -(maxTickWidth + titleFontSize + titlePadding) - : tickSize + tickPadding + maxTickWidth + titlePadding; + ? -(maxLabelBboxWidth + titleFontSize + titlePadding) + : tickSize + tickPadding + maxLabelBboxWidth + titlePadding; return ( @@ -186,7 +204,7 @@ export class Axis extends React.PureComponent { const { axisPosition: { width, height }, axisSpec: { title, position, tickSize, tickPadding }, - axisTicksDimensions: { maxTickHeight }, + axisTicksDimensions: { maxLabelBboxHeight }, chartTheme: { axes: { titleFontSize }, }, @@ -197,7 +215,7 @@ export class Axis extends React.PureComponent { return; } - const top = position === Position.Top ? -maxTickHeight : maxTickHeight + tickPadding + tickSize; + const top = position === Position.Top ? -maxLabelBboxHeight : maxLabelBboxHeight + tickPadding + tickSize; const left = 0; return ( @@ -206,7 +224,7 @@ export class Axis extends React.PureComponent { x={left} y={top} width={width} - height={maxTickHeight} + height={maxLabelBboxHeight} stroke="black" strokeWidth={1} fill="violet" diff --git a/src/components/react_canvas/reactive_chart.tsx b/src/components/react_canvas/reactive_chart.tsx index fb81a1bc78a..580e186386f 100644 --- a/src/components/react_canvas/reactive_chart.tsx +++ b/src/components/react_canvas/reactive_chart.tsx @@ -134,7 +134,9 @@ class Chart extends React.Component { axesPositions, chartTheme, debug, + chartDimensions, } = this.props.chartStore!; + const axesComponents: JSX.Element[] = []; axesVisibleTicks.forEach((axisTicks, axisId) => { const axisSpec = axesSpecs.get(axisId); @@ -153,6 +155,7 @@ class Chart extends React.Component { ticks={ticks} chartTheme={chartTheme} debug={debug} + chartDimensions={chartDimensions} />, ); }); @@ -231,15 +234,15 @@ class Chart extends React.Component { const clippings = debug ? {} : { - clipX: 0, - clipY: 0, - clipWidth: [90, -90].includes(chartRotation) - ? chartDimensions.height - : chartDimensions.width, - clipHeight: [90, -90].includes(chartRotation) - ? chartDimensions.width - : chartDimensions.height, - }; + clipX: 0, + clipY: 0, + clipWidth: [90, -90].includes(chartRotation) + ? chartDimensions.height + : chartDimensions.width, + clipHeight: [90, -90].includes(chartRotation) + ? chartDimensions.width + : chartDimensions.height, + }; let brushProps = {}; const isBrushEnabled = this.props.chartStore!.isBrushEnabled(); if (isBrushEnabled) { diff --git a/src/components/svg/axis.tsx b/src/components/svg/axis.tsx index effd0e398a1..d0732e3948f 100644 --- a/src/components/svg/axis.tsx +++ b/src/components/svg/axis.tsx @@ -40,9 +40,9 @@ export class Axis extends React.PureComponent { className="euiSeriesChartAxis_tickLabel" key={`tick-${i}`} {...textProps} - // textAnchor={textProps.textAnchor} - // dominantBaseline={textProps.dominantBaseline} - // transform={transform} + // textAnchor={textProps.textAnchor} + // dominantBaseline={textProps.dominantBaseline} + // transform={transform} > {tick.label} @@ -52,7 +52,7 @@ export class Axis extends React.PureComponent { private renderTickLine = (tick: AxisTick, i: number) => { const { axisSpec: { tickSize, tickPadding, position }, - axisTicksDimensions: { maxTickHeight }, + axisTicksDimensions: { maxLabelBboxHeight }, } = this.props; const lineProps: SVGProps = {}; @@ -65,8 +65,8 @@ export class Axis extends React.PureComponent { } else { lineProps.x1 = tick.position; lineProps.x2 = tick.position; - lineProps.y1 = position === 'top' ? maxTickHeight + tickPadding : 0; - lineProps.y2 = position === 'top' ? maxTickHeight + tickPadding + tickSize : tickSize; + lineProps.y1 = position === 'top' ? maxLabelBboxHeight + tickPadding : 0; + lineProps.y2 = position === 'top' ? maxLabelBboxHeight + tickPadding + tickSize : tickSize; } return ; @@ -101,9 +101,9 @@ export class Axis extends React.PureComponent { lineProps.x1 = 0; lineProps.x2 = axisPosition.width; lineProps.y1 = - position === 'top' ? axisTicksDimensions.maxTickHeight + tickSize + tickPadding : 0; + position === 'top' ? axisTicksDimensions.maxLabelBboxHeight + tickSize + tickPadding : 0; lineProps.y2 = - position === 'top' ? axisTicksDimensions.maxTickHeight + tickSize + tickPadding : 0; + position === 'top' ? axisTicksDimensions.maxLabelBboxHeight + tickSize + tickPadding : 0; } return ; } @@ -123,7 +123,7 @@ export class Axis extends React.PureComponent { const { axisPosition: { height }, axisSpec: { title, position, tickSize, tickPadding }, - axisTicksDimensions: { maxTickWidth }, + axisTicksDimensions: { maxLabelBboxWidth }, chartTheme: { chart: { margins }, }, @@ -132,8 +132,8 @@ export class Axis extends React.PureComponent { const top = height / 2; const left = position === Position.Left - ? -(maxTickWidth + margins.left / 2) - : tickSize + tickPadding + maxTickWidth + +margins.right / 2; + ? -(maxLabelBboxWidth + margins.left / 2) + : tickSize + tickPadding + maxLabelBboxWidth + +margins.right / 2; const translate = `translate(${left} ${top}) rotate(-90)`; return ( @@ -147,7 +147,7 @@ export class Axis extends React.PureComponent { const { axisPosition: { width }, axisSpec: { title, position, tickSize, tickPadding }, - axisTicksDimensions: { maxTickHeight }, + axisTicksDimensions: { maxLabelBboxHeight }, chartTheme: { chart: { margins }, }, @@ -156,7 +156,7 @@ export class Axis extends React.PureComponent { const top = position === Position.Top ? -margins.top / 2 - : maxTickHeight + tickPadding + tickSize + margins.bottom / 2; + : maxLabelBboxHeight + tickPadding + tickSize + margins.bottom / 2; const left = width / 2; const translate = `translate(${left} ${top} )`; return ( diff --git a/src/lib/axes/axis_utils.test.ts b/src/lib/axes/axis_utils.test.ts index b6d442005b3..9717383d89b 100644 --- a/src/lib/axes/axis_utils.test.ts +++ b/src/lib/axes/axis_utils.test.ts @@ -1,14 +1,17 @@ import { XDomain } from '../series/domains/x_domain'; import { YDomain } from '../series/domains/y_domain'; import { Position } from '../series/specs'; -// import { ScalesConfig } from '../themes/theme'; +import { DEFAULT_THEME } from '../themes/theme'; import { getAxisId, getGroupId } from '../utils/ids'; import { ScaleType } from '../utils/scales/scales'; import { + centerRotationOrigin, computeAxisTicksDimensions, + computeRotatedLabelDimensions, getAvailableTicks, getMinMaxRange, getScaleForAxisSpec, + getTickLabelProps, getVisibleTicks, } from './axis_utils'; import { SvgTextBBoxCalculator } from './svg_text_bbox_calculator'; @@ -33,7 +36,7 @@ describe('Axis computational utils', () => { const originalGetBBox = SVGElement.prototype.getBoundingClientRect; beforeEach( () => - (SVGElement.prototype.getBoundingClientRect = function() { + (SVGElement.prototype.getBoundingClientRect = function () { const text = this.textContent || 0; return { ...mockedRect, width: Number(text) * 10, heigh: Number(text) * 10 }; }), @@ -49,23 +52,12 @@ describe('Axis computational utils', () => { const axis1Dims = { axisScaleType: ScaleType.Linear, axisScaleDomain: [0, 1], - ticksDimensions: [ - { width: 0, height: 10 }, - { width: 1, height: 10 }, - { width: 2, height: 10 }, - { width: 3, height: 10 }, - { width: 4, height: 10 }, - { width: 5, height: 10 }, - { width: 6, height: 10 }, - { width: 7, height: 10 }, - { width: 8, height: 10 }, - { width: 9, height: 10 }, - { width: 10, height: 10 }, - ], tickValues: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1], tickLabels: ['0', '0.1', '0.2', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9', '1'], - maxTickWidth: 10, - maxTickHeight: 10, + maxLabelBboxWidth: 10, + maxLabelBboxHeight: 10, + maxLabelTextWidth: 10, + maxLabelTextHeight: 10, }; const verticalAxisSpec = { id: getAxisId('axis_1'), @@ -97,6 +89,8 @@ describe('Axis computational utils', () => { isBandScale: false, }; + const { axes } = DEFAULT_THEME; + test('should compute axis dimensions', () => { const bboxCalculator = new SvgTextBBoxCalculator(); const axisDimensions = computeAxisTicksDimensions( @@ -106,11 +100,25 @@ describe('Axis computational utils', () => { 1, bboxCalculator, 0, + axes, ); expect(axisDimensions).toEqual(axis1Dims); bboxCalculator.destroy(); }); + test('should compute dimensions for the bounding box containing a rotated label', () => { + expect(computeRotatedLabelDimensions({ width: 1, height: 2 }, 0)).toEqual({ width: 1, height: 2 }); + + const dims90 = computeRotatedLabelDimensions({ width: 1, height: 2 }, 90); + expect(dims90.width).toBeCloseTo(2); + expect(dims90.height).toBeCloseTo(1); + + const dims45 = computeRotatedLabelDimensions({ width: 1, height: 1 }, 45); + expect(dims45.width).toBeCloseTo(Math.sqrt(2)); + expect(dims45.height).toBeCloseTo(Math.sqrt(2)); + }); + + // TODO: these tests appear to be failing (also on master) test('should generate a valid scale', () => { const scale = getScaleForAxisSpec(verticalAxisSpec, xDomain, [yDomain], 0, 0, 0, 100); expect(scale).toBeDefined(); @@ -120,6 +128,7 @@ describe('Axis computational utils', () => { expect(scale!.ticks()).toEqual([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]); }); + // TODO: these tests appear to be failing (also on master) test('should compute available ticks', () => { const scale = getScaleForAxisSpec(verticalAxisSpec, xDomain, [yDomain], 0, 0, 0, 100); const axisPositions = getAvailableTicks(verticalAxisSpec, scale!, 0); @@ -185,23 +194,12 @@ describe('Axis computational utils', () => { const axis2Dims = { axisScaleType: ScaleType.Linear, axisScaleDomain: [0, 1], - ticksDimensions: [ - { width: 0, height: 20 }, - { width: 1, height: 20 }, - { width: 2, height: 20 }, - { width: 3, height: 20 }, - { width: 4, height: 20 }, - { width: 5, height: 20 }, - { width: 6, height: 20 }, - { width: 7, height: 20 }, - { width: 8, height: 20 }, - { width: 9, height: 20 }, - { width: 10, height: 20 }, - ], tickValues: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1], tickLabels: ['0', '0.1', '0.2', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9', '1'], - maxTickWidth: 10, - maxTickHeight: 20, + maxLabelBboxWidth: 10, + maxLabelBboxHeight: 20, + maxLabelTextWidth: 10, + maxLabelTextHeight: 20, }; const visibleTicks = getVisibleTicks(allTicks, verticalAxisSpec, axis2Dims, chartDim, 0); const expectedVisibleTicks = [ @@ -250,4 +248,150 @@ describe('Axis computational utils', () => { }); expect(minMax).toEqual({ minRange: 100, maxRange: 0 }); }); + + test('should compute coordinates and offsets to anchor rotation origin from the center', () => { + const simpleCenteredProps = centerRotationOrigin({ + maxLabelBboxWidth: 10, + maxLabelBboxHeight: 20, + maxLabelTextWidth: 10, + maxLabelTextHeight: 20, + }, { x: 0, y: 0 }); + + expect(simpleCenteredProps).toEqual({ + offsetX: 5, + offsetY: 10, + x: 5, + y: 10, + }); + + const rotatedCenteredProps = centerRotationOrigin({ + maxLabelBboxWidth: 10, + maxLabelBboxHeight: 20, + maxLabelTextWidth: 20, + maxLabelTextHeight: 10, + }, { x: 30, y: 40 }); + + expect(rotatedCenteredProps).toEqual({ + offsetX: 10, + offsetY: 5, + x: 35, + y: 50, + }); + }); + + test('should compute positions and alignment of tick labels along a vertical axis', () => { + let tickLabelRotation = 0; + const tickSize = 10; + const tickPadding = 5; + const tickPosition = 0; + let axisPosition = Position.Left; + + const unrotatedLabelProps = getTickLabelProps( + tickLabelRotation, + tickSize, + tickPadding, + tickPosition, + axisPosition, + axis1Dims, + ); + + expect(unrotatedLabelProps).toEqual({ + x: -10, + y: -5, + align: 'right', + verticalAlign: 'middle', + }); + + tickLabelRotation = 90; + const rotatedLabelProps = getTickLabelProps( + tickLabelRotation, + tickSize, + tickPadding, + tickPosition, + axisPosition, + axis1Dims, + ); + + expect(rotatedLabelProps).toEqual({ + x: -10, + y: -5, + align: 'center', + verticalAlign: 'middle', + }); + + axisPosition = Position.Right; + const rightRotatedLabelProps = getTickLabelProps( + tickLabelRotation, + tickSize, + tickPadding, + tickPosition, + axisPosition, + axis1Dims, + ); + + expect(rightRotatedLabelProps).toEqual({ + x: 15, + y: -5, + align: 'center', + verticalAlign: 'middle', + }); + }); + + test('should compute positions and alignment of tick labels along a horizontal axis', () => { + let tickLabelRotation = 0; + const tickSize = 10; + const tickPadding = 5; + const tickPosition = 0; + let axisPosition = Position.Top; + + const unrotatedLabelProps = getTickLabelProps( + tickLabelRotation, + tickSize, + tickPadding, + tickPosition, + axisPosition, + axis1Dims, + ); + + expect(unrotatedLabelProps).toEqual({ + x: -5, + y: 0, + align: 'center', + verticalAlign: 'bottom', + }); + + tickLabelRotation = 90; + const rotatedLabelProps = getTickLabelProps( + tickLabelRotation, + tickSize, + tickPadding, + tickPosition, + axisPosition, + axis1Dims, + ); + + expect(rotatedLabelProps).toEqual({ + x: -5, + y: 0, + align: 'center', + verticalAlign: 'middle', + }); + + axisPosition = Position.Bottom; + const bottomRotatedLabelProps = getTickLabelProps( + tickLabelRotation, + tickSize, + tickPadding, + tickPosition, + axisPosition, + axis1Dims, + ); + + expect(bottomRotatedLabelProps).toEqual({ + x: -5, + y: 15, + align: 'center', + verticalAlign: 'middle', + }); + }); }); diff --git a/src/lib/axes/axis_utils.ts b/src/lib/axes/axis_utils.ts index d0d6616f580..6e278d3d2ae 100644 --- a/src/lib/axes/axis_utils.ts +++ b/src/lib/axes/axis_utils.ts @@ -1,14 +1,14 @@ -import { max } from 'd3-array'; + import { XDomain } from '../series/domains/x_domain'; import { YDomain } from '../series/domains/y_domain'; import { computeXScale, computeYScales } from '../series/scales'; import { AxisSpec, Position, Rotation, TickFormatter } from '../series/specs'; -import { ChartConfig, LegendStyle } from '../themes/theme'; +import { AxisConfig, Theme } from '../themes/theme'; import { Dimensions, Margins } from '../utils/dimensions'; import { Domain } from '../utils/domain'; import { AxisId } from '../utils/ids'; import { Scale, ScaleType } from '../utils/scales/scales'; -import { BBoxCalculator } from './bbox_calculator'; +import { BBox, BBoxCalculator } from './bbox_calculator'; export interface AxisTick { value: number | string; @@ -20,10 +20,18 @@ export interface AxisTicksDimensions { axisScaleType: ScaleType; axisScaleDomain: Domain; tickValues: string[] | number[]; - ticksDimensions: Array<{ width: number; height: number }>; tickLabels: string[]; - maxTickWidth: number; - maxTickHeight: number; + maxLabelBboxWidth: number; + maxLabelBboxHeight: number; + maxLabelTextWidth: number; + maxLabelTextHeight: number; +} + +export interface TickLabelProps { + x: number; + y: number; + align: string; + verticalAlign: string; } /** @@ -40,6 +48,7 @@ export function computeAxisTicksDimensions( totalGroupCount: number, bboxCalculator: BBoxCalculator, chartRotation: Rotation, + axisConfig: AxisConfig, ): AxisTicksDimensions | null { const scale = getScaleForAxisSpec( axisSpec, @@ -53,7 +62,14 @@ export function computeAxisTicksDimensions( if (!scale) { throw new Error(`Cannot compute scale for axis spec ${axisSpec.id}`); } - const dimensions = computeTickDimensions(scale, axisSpec.tickFormat, bboxCalculator); + const dimensions = computeTickDimensions( + scale, + axisSpec.tickFormat, + bboxCalculator, + axisConfig, + axisSpec.tickLabelRotation, + ); + return { axisScaleDomain: xDomain.domain, axisScaleType: xDomain.scaleType, @@ -81,37 +97,198 @@ export function getScaleForAxisSpec( } } +export function computeRotatedLabelDimensions(unrotatedDims: BBox, degreesRotation: number): BBox { + const { width, height } = unrotatedDims; + + const radians = degreesRotation * Math.PI / 180; + + const rotatedHeight = Math.abs(width * Math.sin(radians)) + Math.abs(height * Math.cos(radians)); + const rotatedWidth = Math.abs(width * Math.cos(radians)) + Math.abs(height * Math.sin(radians)); + + return { + width: rotatedWidth, + height: rotatedHeight, + }; +} + function computeTickDimensions( scale: Scale, tickFormat: TickFormatter, bboxCalculator: BBoxCalculator, + axisConfig: AxisConfig, + tickLabelRotation: number = 0, ) { const tickValues = scale.ticks(); const tickLabels = tickValues.map(tickFormat); - const ticksDimensions = tickLabels - .map((tickLabel: string) => { - const bbox = bboxCalculator.compute(tickLabel).getOrElse({ + const { tickFontSize, tickFontFamily } = axisConfig; + + const { maxLabelBboxWidth, maxLabelBboxHeight, maxLabelTextWidth, maxLabelTextHeight } = tickLabels + .reduce((acc: { [key: string]: number }, tickLabel: string) => { + const bbox = bboxCalculator.compute(tickLabel, tickFontSize, tickFontFamily).getOrElse({ width: 0, height: 0, }); + + const rotatedBbox = computeRotatedLabelDimensions(bbox, tickLabelRotation); + + const width = Math.ceil(rotatedBbox.width); + const height = Math.ceil(rotatedBbox.height); + const labelWidth = Math.ceil(bbox.width); + const labelHeight = Math.ceil(bbox.height); + + const prevWidth = acc.maxLabelBboxWidth; + const prevHeight = acc.maxLabelBboxHeight; + const prevLabelWidth = acc.maxLabelTextWidth; + const prevLabelHeight = acc.maxLabelTextHeight; + return { - width: Math.ceil(bbox.width), - height: Math.ceil(bbox.height), + maxLabelBboxWidth: prevWidth > width ? prevWidth : width, + maxLabelBboxHeight: prevHeight > height ? prevHeight : height, + maxLabelTextWidth: prevLabelWidth > labelWidth ? prevLabelWidth : labelWidth, + maxLabelTextHeight: prevLabelHeight > labelHeight ? prevLabelHeight : labelHeight, }; - }) - .filter((d) => d); - const maxTickWidth = max(ticksDimensions, (bbox) => bbox.width) || 0; - const maxTickHeight = max(ticksDimensions, (bbox) => bbox.height) || 0; + }, { maxLabelBboxWidth: 0, maxLabelBboxHeight: 0, maxLabelTextWidth: 0, maxLabelTextHeight: 0 }); + return { tickValues, tickLabels, - ticksDimensions, - maxTickWidth, - maxTickHeight, + maxLabelBboxWidth, + maxLabelBboxHeight, + maxLabelTextWidth, + maxLabelTextHeight, }; } +/** + * The Konva api sets the top right corner of a shape as the default origin of rotation. + * In order to apply rotation to tick labels while preserving their relative position to the axis, + * we compute offsets to apply to the Text element as well as adjust the x/y coordinates to adjust + * for these offsets. + */ +export function centerRotationOrigin( + axisTicksDimensions: { + maxLabelBboxWidth: number, + maxLabelBboxHeight: number, + maxLabelTextWidth: number, + maxLabelTextHeight: number, + }, + coordinates: { x: number, y: number }): { x: number, y: number, offsetX: number, offsetY: number } { + + const { maxLabelBboxWidth, maxLabelBboxHeight, maxLabelTextWidth, maxLabelTextHeight } = axisTicksDimensions; + + const offsetX = maxLabelTextWidth / 2; + const offsetY = maxLabelTextHeight / 2; + const x = coordinates.x + maxLabelBboxWidth / 2; + const y = coordinates.y + maxLabelBboxHeight / 2; + + return { offsetX, offsetY, x, y }; +} + +/** + * Gets the computed x/y coordinates & alignment properties for an axis tick label. + * @param isVerticalAxis if the axis is vertical (in contrast to horizontal) + * @param tickLabelRotation degree of rotation of the tick label + * @param tickSize length of tick line + * @param tickPadding amount of padding between label and tick line + * @param tickPosition position of tick relative to axis line origin and other ticks along it + * @param axisPosition position of where the axis sits relative to the visualization + * @param axisTicksDimensions computed axis dimensions and values (from computeTickDimensions) + */ +export function getTickLabelProps( + tickLabelRotation: number, + tickSize: number, + tickPadding: number, + tickPosition: number, + axisPosition: Position, + axisTicksDimensions: AxisTicksDimensions, +): TickLabelProps { + const { maxLabelBboxWidth, maxLabelBboxHeight } = axisTicksDimensions; + const isVerticalAxis = isVertical(axisPosition); + const isRotated = tickLabelRotation !== 0; + let align = 'center'; + let verticalAlign = 'middle'; + + if (isVerticalAxis) { + const isAxisLeft = axisPosition === Position.Left; + + if (!isRotated) { + align = isAxisLeft ? 'right' : 'left'; + } + + return { + x: isAxisLeft ? - (maxLabelBboxWidth) : tickSize + tickPadding, + y: tickPosition - maxLabelBboxHeight / 2, + align, + verticalAlign, + }; + } + + const isAxisTop = axisPosition === Position.Top; + + if (!isRotated) { + verticalAlign = isAxisTop ? 'bottom' : 'top'; + } + + return { + x: tickPosition - maxLabelBboxWidth / 2, + y: isAxisTop ? 0 : tickSize + tickPadding, + align, + verticalAlign, + }; +} + +export function getVerticalAxisTickLineProps( + showGridLine: boolean, + position: Position, + tickPadding: number, + tickSize: number, + tickPosition: number, + chartWidth: number, + paddings: Margins, +): number[] { + const isLeftAxis = position === Position.Left; + const y = tickPosition; + const x1 = isLeftAxis ? tickPadding : 0; + const x2 = isLeftAxis ? tickSize + tickPadding : tickSize; + + if (showGridLine) { + if (isLeftAxis) { + return [x1, y, x2 + chartWidth + paddings.left, y]; + } + + return [x1 - chartWidth - paddings.right, y, x2, y]; + } + + return [x1, y, x2, y]; +} + +export function getHorizontalAxisTickLineProps( + showGridLine: boolean, + position: Position, + tickPadding: number, + tickSize: number, + tickPosition: number, + labelHeight: number, + chartHeight: number, + paddings: Margins, +): number[] { + const isTopAxis = position === Position.Top; + const x = tickPosition; + const y1 = isTopAxis ? labelHeight + tickPadding : 0; + const y2 = isTopAxis ? labelHeight + tickPadding + tickSize : tickSize; + + if (showGridLine) { + if (isTopAxis) { + return [x, y1, x, y2 + chartHeight + paddings.top]; + } + + return [x, y1 - chartHeight - paddings.bottom, x, y2]; + } + + return [x, y1, x, y2]; +} + export function getMinMaxRange( axisPosition: Position, chartRotation: Rotation, @@ -184,9 +361,9 @@ export function getVisibleTicks( chartRotation: Rotation, ): AxisTick[] { const { showOverlappingTicks, showOverlappingLabels } = axisSpec; - const { maxTickHeight, maxTickWidth } = axisDim; + const { maxLabelBboxHeight, maxLabelBboxWidth } = axisDim; const { width, height } = chartDimensions; - const requiredSpace = isVertical(axisSpec.position) ? maxTickHeight / 2 : maxTickWidth / 2; + const requiredSpace = isVertical(axisSpec.position) ? maxLabelBboxHeight / 2 : maxLabelBboxWidth / 2; let firstTickPosition; firstTickPosition = 0; @@ -234,6 +411,7 @@ export function getVisibleTicks( export function getAxisPosition( chartDimensions: Dimensions, chartMargins: Margins, + axisTitleHeight: number, axisSpec: AxisSpec, axisDim: AxisTicksDimensions, cumTopSum: number, @@ -241,9 +419,8 @@ export function getAxisPosition( cumLeftSum: number, cumRightSum: number, ) { - // TODO add title space const { position, tickSize, tickPadding } = axisSpec; - const { maxTickHeight, maxTickWidth } = axisDim; + const { maxLabelBboxHeight, maxLabelBboxWidth } = axisDim; const { top, left, height, width } = chartDimensions; const dimensions = { top, @@ -258,31 +435,30 @@ export function getAxisPosition( if (isVertical(position)) { if (position === Position.Left) { - leftIncrement = maxTickWidth + tickSize + tickPadding + chartMargins.left; - dimensions.left = maxTickWidth + cumLeftSum + chartMargins.left; + leftIncrement = maxLabelBboxWidth + tickSize + tickPadding + chartMargins.left; + dimensions.left = maxLabelBboxWidth + cumLeftSum + chartMargins.left + axisTitleHeight; } else { - rightIncrement = maxTickWidth + tickSize + tickPadding + chartMargins.right; + rightIncrement = maxLabelBboxWidth + tickSize + tickPadding + chartMargins.right; dimensions.left = left + width + cumRightSum; } - dimensions.width = maxTickWidth; + dimensions.width = maxLabelBboxWidth; } else { if (position === Position.Top) { - topIncrement = maxTickHeight + tickSize + tickPadding + chartMargins.top; - dimensions.top = cumTopSum + chartMargins.top; + topIncrement = maxLabelBboxHeight + tickSize + tickPadding + chartMargins.top; + dimensions.top = cumTopSum + chartMargins.top + axisTitleHeight; } else { - bottomIncrement = maxTickHeight + tickSize + tickPadding + chartMargins.bottom; + bottomIncrement = maxLabelBboxHeight + tickSize + tickPadding + chartMargins.bottom; dimensions.top = top + height + cumBottomSum; } - dimensions.height = maxTickHeight; + dimensions.height = maxLabelBboxHeight; } return { dimensions, topIncrement, bottomIncrement, leftIncrement, rightIncrement }; } export function getAxisTicksPositions( chartDimensions: Dimensions, - chartConfig: ChartConfig, + chartTheme: Theme, chartRotation: Rotation, - legendStyle: LegendStyle, showLegend: boolean, axisSpecs: Map, axisDimensions: Map, @@ -291,9 +467,12 @@ export function getAxisTicksPositions( totalGroupsCount: number, legendPosition?: Position, ) { + const chartConfig = chartTheme.chart; + const legendStyle = chartTheme.legend; const axisPositions: Map = new Map(); const axisVisibleTicks: Map = new Map(); const axisTicks: Map = new Map(); + let cumTopSum = 0; let cumBottomSum = chartConfig.paddings.bottom; let cumLeftSum = 0; @@ -351,9 +530,14 @@ export function getAxisTicksPositions( chartDimensions, chartRotation, ); + + const { titleFontSize, titlePadding } = chartTheme.axes; + const axisTitleHeight = titleFontSize + titlePadding; + const axisPosition = getAxisPosition( chartDimensions, chartConfig.margins, + axisTitleHeight, axisSpec, axisDim, cumTopSum, @@ -361,6 +545,7 @@ export function getAxisTicksPositions( cumLeftSum, cumRightSum, ); + cumTopSum += axisPosition.topIncrement; cumBottomSum += axisPosition.bottomIncrement; cumLeftSum += axisPosition.leftIncrement; diff --git a/src/lib/axes/bbox_calculator.ts b/src/lib/axes/bbox_calculator.ts index 796749c40f8..8d8bffe953a 100644 --- a/src/lib/axes/bbox_calculator.ts +++ b/src/lib/axes/bbox_calculator.ts @@ -6,6 +6,6 @@ export interface BBox { } export interface BBoxCalculator { - compute(text: string): Option; + compute(text: string, fontSize?: number, fontFamily?: string): Option; destroy(): void; } diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index ddecf74de5f..132e5ebe62e 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -111,6 +111,8 @@ export interface AxisSpec { showOverlappingTicks: boolean; /** Shows all labels, also the overlapping ones */ showOverlappingLabels: boolean; + /** Shows grid lines for axis; default false */ + showGridLines?: boolean; /** Where the axis appear on the chart */ position: Position; /** The length of the tick line */ @@ -119,6 +121,8 @@ export interface AxisSpec { tickPadding: number; /** A function called to format each single tick label */ tickFormat: TickFormatter; + /** The degrees of rotation of the tick labels */ + tickLabelRotation?: number; /** The axis title */ title?: string; } diff --git a/src/lib/themes/theme.ts b/src/lib/themes/theme.ts index d75f2374b86..34d8aa775b2 100644 --- a/src/lib/themes/theme.ts +++ b/src/lib/themes/theme.ts @@ -1,7 +1,12 @@ import { Margins } from '../utils/dimensions'; export interface ChartConfig { + /* Space btw parent DOM element and first available element of the chart (axis + * if exists, else the chart itself) + */ margins: Margins; + + /* Space btw the chart geometries and axis; if no axis, pads space btw chart & container */ paddings: Margins; styles: { lineSeries: LineSeriesStyle; diff --git a/src/lib/utils/dimensions.test.ts b/src/lib/utils/dimensions.test.ts index 4e6e4941eaf..ab04e3d9522 100644 --- a/src/lib/utils/dimensions.test.ts +++ b/src/lib/utils/dimensions.test.ts @@ -1,6 +1,6 @@ import { AxisTicksDimensions } from '../axes/axis_utils'; import { AxisSpec, Position } from '../series/specs'; -import { LegendStyle } from '../themes/theme'; +import { DEFAULT_THEME, LegendStyle } from '../themes/theme'; import { computeChartDimensions, Margins } from './dimensions'; import { AxisId, getAxisId, getGroupId } from './ids'; import { ScaleType } from './scales/scales'; @@ -31,8 +31,10 @@ describe('Computed chart dimensions', () => { tickValues: [0, 1], ticksDimensions: [{ width: 10, height: 10 }, { width: 10, height: 10 }], tickLabels: ['first', 'second'], - maxTickWidth: 10, - maxTickHeight: 10, + maxLabelBboxWidth: 10, + maxLabelBboxHeight: 10, + maxLabelTextWidth: 10, + maxLabelTextHeight: 10, }; const axis1Spec = { id: getAxisId('axis_1'), @@ -52,14 +54,22 @@ describe('Computed chart dimensions', () => { horizontalHeight: 0, }; const showLegend = false; + + const chartTheme = { + ...DEFAULT_THEME, + chart: { + ...DEFAULT_THEME.chart, + margins: chartMargins, + paddings: chartPaddings, + }, + ...legend, + }; test('should be equal to parent dimension with no axis minus margins', () => { const axisDims = new Map(); const axisSpecs = new Map(); const chartDimensions = computeChartDimensions( parentDim, - chartMargins, - chartPaddings, - legend, + chartTheme, axisDims, axisSpecs, showLegend, @@ -73,9 +83,7 @@ describe('Computed chart dimensions', () => { axisSpecs.set(getAxisId('axis_1'), axis1Spec); const chartDimensions = computeChartDimensions( parentDim, - chartMargins, - chartPaddings, - legend, + chartTheme, axisDims, axisSpecs, showLegend, @@ -89,9 +97,7 @@ describe('Computed chart dimensions', () => { axisSpecs.set(getAxisId('axis_1'), { ...axis1Spec, position: Position.Right }); const chartDimensions = computeChartDimensions( parentDim, - chartMargins, - chartPaddings, - legend, + chartTheme, axisDims, axisSpecs, showLegend, @@ -108,9 +114,7 @@ describe('Computed chart dimensions', () => { }); const chartDimensions = computeChartDimensions( parentDim, - chartMargins, - chartPaddings, - legend, + chartTheme, axisDims, axisSpecs, showLegend, @@ -127,9 +131,7 @@ describe('Computed chart dimensions', () => { }); const chartDimensions = computeChartDimensions( parentDim, - chartMargins, - chartPaddings, - legend, + chartTheme, axisDims, axisSpecs, showLegend, diff --git a/src/lib/utils/dimensions.ts b/src/lib/utils/dimensions.ts index 4747c2cc487..2da01708e50 100644 --- a/src/lib/utils/dimensions.ts +++ b/src/lib/utils/dimensions.ts @@ -1,6 +1,6 @@ import { AxisTicksDimensions } from '../axes/axis_utils'; import { AxisSpec, Position } from '../series/specs'; -import { LegendStyle } from '../themes/theme'; +import { Theme } from '../themes/theme'; import { AxisId } from './ids'; export interface Dimensions { @@ -24,20 +24,25 @@ export interface Margins { */ export function computeChartDimensions( parentDimensions: Dimensions, - chartMargins: Margins, - chartPaddings: Margins, - legendStyle: LegendStyle, + chartTheme: Theme, axisDimensions: Map, axisSpecs: Map, showLegend: boolean, legendPosition?: Position, ): Dimensions { + const chartMargins = chartTheme.chart.margins; + const chartPaddings = chartTheme.chart.paddings; + const legendStyle = chartTheme.legend; + const { titleFontSize, titlePadding } = chartTheme.axes; + + const axisTitleHeight = titleFontSize + titlePadding; + let vLeftAxisSpecWidth = 0; let vRightAxisSpecWidth = 0; let hTopAxisSpecHeight = 0; let hBottomAxisSpecHeight = 0; - axisDimensions.forEach(({ maxTickWidth = 0, maxTickHeight = 0 }, id) => { + axisDimensions.forEach(({ maxLabelBboxWidth = 0, maxLabelBboxHeight = 0 }, id) => { const axisSpec = axisSpecs.get(id); if (!axisSpec) { return; @@ -45,16 +50,16 @@ export function computeChartDimensions( const { position, tickSize, tickPadding } = axisSpec; switch (position) { case Position.Top: - hTopAxisSpecHeight += maxTickHeight + tickSize + tickPadding + chartMargins.top; + hTopAxisSpecHeight += maxLabelBboxHeight + tickSize + tickPadding + chartMargins.top + axisTitleHeight; break; case Position.Bottom: - hBottomAxisSpecHeight += maxTickHeight + tickSize + tickPadding + chartMargins.bottom; + hBottomAxisSpecHeight += maxLabelBboxHeight + tickSize + tickPadding + chartMargins.bottom + axisTitleHeight; break; case Position.Left: - vLeftAxisSpecWidth += maxTickWidth + tickSize + tickPadding + chartMargins.left; + vLeftAxisSpecWidth += maxLabelBboxWidth + tickSize + tickPadding + chartMargins.left + axisTitleHeight; break; case Position.Right: - vRightAxisSpecWidth += maxTickWidth + tickSize + tickPadding + chartMargins.right; + vRightAxisSpecWidth += maxLabelBboxWidth + tickSize + tickPadding + chartMargins.right + axisTitleHeight; break; } }); diff --git a/src/playground/index.tsx b/src/playground/index.tsx index 608389e9a7c..21616f3ac96 100644 --- a/src/playground/index.tsx +++ b/src/playground/index.tsx @@ -78,6 +78,7 @@ class App extends Component { showOverlappingTicks={true} /> { tickSize: 10, tickPadding: 10, tickFormat: (tick: any) => `${tick}`, + tickLabelRotation: 0, }; componentDidMount() { const { chartStore, children, ...spec } = this.props; diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index ffe5f3527f7..64504b93475 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -313,6 +313,7 @@ export class ChartStore { totalGroupCount, bboxCalculator, this.chartRotation, + this.chartTheme.axes, ); if (dimensions) { this.axesTicksDimensions.set(id, dimensions); @@ -323,9 +324,7 @@ export class ChartStore { // // compute chart dimensions this.chartDimensions = computeChartDimensions( this.parentDimensions, - this.chartTheme.chart.margins, - this.chartTheme.chart.paddings, - this.chartTheme.legend, + this.chartTheme, this.axesTicksDimensions, this.axesSpecs, this.showLegend.get() && !this.legendCollapsed.get(), @@ -359,9 +358,8 @@ export class ChartStore { // // compute visible ticks and their positions const axisTicksPositions = getAxisTicksPositions( this.chartDimensions, - this.chartTheme.chart, + this.chartTheme, this.chartRotation, - this.chartTheme.legend, this.showLegend.get() && !this.legendCollapsed.get(), this.axesSpecs, this.axesTicksDimensions, diff --git a/src/stories/axis.tsx b/src/stories/axis.tsx index 12d9e4ebd48..2dbcfc2f336 100644 --- a/src/stories/axis.tsx +++ b/src/stories/axis.tsx @@ -1,4 +1,4 @@ -import { boolean } from '@storybook/addon-knobs'; +import { boolean, number } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import React from 'react'; import { @@ -46,6 +46,74 @@ storiesOf('Axis', module) ); }) + .add('tick label rotation', () => { + return ( + + + Number(d).toFixed(2)} + /> + Number(d).toFixed(2)} + /> + Number(d).toFixed(2)} + /> + + + ); + }) .add('4 axes', () => { return ( diff --git a/src/stories/styling.tsx b/src/stories/styling.tsx index da95cb668b9..fb5dda7a250 100644 --- a/src/stories/styling.tsx +++ b/src/stories/styling.tsx @@ -39,12 +39,14 @@ storiesOf('Stylings', module) position={Position.Bottom} title={'Bottom axis'} showOverlappingTicks={true} + showGridLines={boolean('show horizontal axis grid lines', false)} /> Number(d).toFixed(2)} + showGridLines={boolean('show vertical axis grid lines', false)} />