feat: implement labelFlush behavior for continuous axes (#117)

* feat: add labelFlush to definition

* feat: implement flushing
This commit is contained in:
Krist Wongsuphasawat 2019-05-31 12:56:07 -07:00 committed by Yongjie Zhao
parent 2333030abf
commit c691415702
7 changed files with 248 additions and 36 deletions

View File

@ -1,6 +1,7 @@
import { LineChartPlugin as LegacyLineChartPlugin } from '../../../../../superset-ui-preset-chart-xy/src/legacy';
import { LineChartPlugin } from '../../../../../superset-ui-preset-chart-xy/src';
import BasicStories from './stories/basic';
import FlushStories from './stories/flush';
import QueryStories from './stories/query';
import LegacyStories from './stories/legacy';
import MissingStories from './stories/missing';
@ -13,6 +14,7 @@ new LineChartPlugin().configure({ key: LINE_PLUGIN_TYPE }).register();
export default {
examples: [
...BasicStories,
...FlushStories,
...MissingStories,
...TimeShiftStories,
...LegacyStories,

View File

@ -0,0 +1,136 @@
/* eslint-disable no-magic-numbers, sort-keys */
import * as React from 'react';
import { SuperChart, ChartProps } from '@superset-ui/chart';
import { radios } from '@storybook/addon-knobs';
import rawData from '../data/data';
import { LINE_PLUGIN_TYPE } from '../constants';
const MIN_TIME = new Date(Date.UTC(1980, 0, 1)).getTime();
const MAX_TIME = new Date(Date.UTC(2000, 1, 1)).getTime();
const data = rawData.filter(({ x }) => x >= MIN_TIME && x <= MAX_TIME);
export default [
{
renderStory: () => [
<SuperChart
key="line1"
chartType={LINE_PLUGIN_TYPE}
chartProps={
new ChartProps({
datasource: { verboseMap: {} },
formData: {
encoding: {
x: {
field: 'x',
type: 'temporal',
format: '%Y',
scale: {
type: 'utc',
},
axis: {
tickCount: 6,
orient: radios('x.axis.orient', { top: 'top', bottom: 'bottom' }, 'bottom'),
title: radios(
'x.axis.title',
{ enable: 'Time', disable: '', '': undefined },
'Time',
),
},
},
y: {
field: 'y',
type: 'quantitative',
scale: {
type: 'linear',
},
axis: {
tickCount: 3,
orient: radios(
'y.axis.orient',
{ left: 'left', right: 'right', '': undefined },
'left',
),
title: radios(
'y.axis.title',
{ enable: 'Score', disable: '', '': undefined },
'Score',
),
},
},
stroke: {
field: 'name',
type: 'nominal',
legend: true,
},
},
},
height: 200,
payload: { data },
width: 400,
})
}
/>,
<SuperChart
key="line1"
chartType={LINE_PLUGIN_TYPE}
chartProps={
new ChartProps({
datasource: { verboseMap: {} },
formData: {
encoding: {
x: {
field: 'x',
type: 'temporal',
format: '%Y',
scale: {
type: 'utc',
},
axis: {
labelFlush: 5,
tickCount: 6,
orient: radios('x.axis.orient', { top: 'top', bottom: 'bottom' }, 'bottom'),
title: radios(
'x.axis.title',
{ enable: 'Time', disable: '', '': undefined },
'Time',
),
},
},
y: {
field: 'y',
type: 'quantitative',
scale: {
type: 'linear',
},
axis: {
tickCount: 3,
orient: radios(
'y.axis.orient',
{ left: 'left', right: 'right', '': undefined },
'left',
),
title: radios(
'y.axis.title',
{ enable: 'Score', disable: '', '': undefined },
'Score',
),
},
},
stroke: {
field: 'name',
type: 'nominal',
legend: true,
},
},
},
height: 200,
payload: { data },
width: 400,
})
}
/>,
],
storyName: 'with labelFlush',
storyPath: 'preset-chart-xy|LineChartPlugin',
},
];

View File

@ -1,7 +1,7 @@
/* eslint-disable no-magic-numbers */
import { CSSProperties } from 'react';
import { Value } from 'vega-lite/build/src/channeldef';
import { getTextDimension, Margin } from '@superset-ui/dimension';
import { getTextDimension, Margin, Dimension } from '@superset-ui/dimension';
import { CategoricalColorScale } from '@superset-ui/color';
import { extractFormatFromTypeAndFormat } from './parsers/extractFormat';
import { CoreAxis, LabelOverlapStrategy, AxisOrient } from './types/Axis';
@ -31,6 +31,19 @@ const DEFAULT_Y_CONFIG: CoreAxis = {
orient: 'left',
};
export interface AxisLayout {
axisWidth: number;
labelAngle: number;
labelFlush: number | boolean;
labelOffset: number;
labelOverlap: 'flat' | 'rotate';
minMargin: Partial<Margin>;
orient: AxisOrient;
tickLabelDimensions: Dimension[];
tickLabels: string[];
tickTextAnchor?: string;
}
export default class AxisAgent<Def extends ChannelDef<Output>, Output extends Value = Value> {
private readonly channelEncoder: ChannelEncoder<Def, Output>;
private readonly format?: (value: any) => string;
@ -108,17 +121,10 @@ export default class AxisAgent<Def extends ChannelDef<Output>, Output extends Va
labelAngle?: number;
tickLength?: number;
tickTextStyle?: CSSProperties;
}): {
labelAngle: number;
labelOffset: number;
labelOverlap: 'flat' | 'rotate';
minMargin: Partial<Margin>;
orient: AxisOrient;
tickTextAnchor?: string;
} {
}): AxisLayout {
const tickLabels = this.getTickLabels();
const labelDimensions = tickLabels.map((text: string) =>
const tickLabelDimensions = tickLabels.map((text: string) =>
getTextDimension({
style: tickTextStyle,
text,
@ -127,7 +133,7 @@ export default class AxisAgent<Def extends ChannelDef<Output>, Output extends Va
const { labelOverlap, labelPadding, orient } = this.config;
const maxWidth = Math.max(...labelDimensions.map(d => d.width), 0);
const maxWidth = Math.max(...tickLabelDimensions.map(d => d.width), 0);
// TODO: Add other strategies: stagger, chop, wrap.
let strategyForLabelOverlap = labelOverlap;
@ -149,7 +155,7 @@ export default class AxisAgent<Def extends ChannelDef<Output>, Output extends Va
if (this.channelEncoder.isX()) {
if (strategyForLabelOverlap === 'flat') {
const labelHeight = labelDimensions.length > 0 ? labelDimensions[0].height : 0;
const labelHeight = tickLabelDimensions.length > 0 ? tickLabelDimensions[0].height : 0;
labelOffset = labelHeight + labelPadding;
requiredMargin += labelHeight;
} else if (strategyForLabelOverlap === 'rotate') {
@ -168,13 +174,21 @@ export default class AxisAgent<Def extends ChannelDef<Output>, Output extends Va
}
return {
axisWidth,
labelAngle: strategyForLabelOverlap === 'flat' ? 0 : labelAngle,
labelFlush:
typeof this.config.labelFlush === 'undefined'
? // If not set, only enable flushing for continuous scales
this.channelEncoder.scale!.scaleTypeCategory === 'continuous'
: this.config.labelFlush,
labelOffset,
labelOverlap: strategyForLabelOverlap,
minMargin: {
[orient]: Math.ceil(requiredMargin),
},
orient,
tickLabelDimensions,
tickLabels,
tickTextAnchor,
};
}

View File

@ -48,6 +48,7 @@ export interface ScaleAgent<Output extends Value> {
| ScaleOrdinal<{ toString(): string }, Output>
| ScalePoint<{ toString(): string }>
| ScaleBand<{ toString(): string }>;
scaleTypeCategory: 'continuous' | 'discrete' | 'discretizing';
}
export interface ScaleTypeToD3ScaleType<Output> {
@ -199,11 +200,26 @@ function createScale<Output extends Value>(
return scale;
}
const continuousScaleTypes = new Set(['linear', 'pow', 'sqrt', 'symlog', 'log', 'time', 'utc']);
const discreteScaleTypes = new Set(['band', 'point']);
const discretizingScaleTypes = new Set(['bin-ordinal', 'quantile', 'quantize', 'threshold']);
function getScaleTypeCategory(scaleType: ScaleType) {
if (continuousScaleTypes.has(scaleType)) {
return 'continuous';
}
if (discreteScaleTypes.has(scaleType)) {
return 'discrete';
}
return 'discretizing';
}
export default function extractScale<Output extends Value>(
channelType: ChannelType,
definition: ChannelDef<Output>,
namespace?: string,
) {
): ScaleAgent<Output> | undefined {
if (isNonValueDef(definition)) {
const scaleConfig =
'scale' in definition && typeof definition.scale !== 'undefined' ? definition.scale : {};
@ -240,6 +256,7 @@ export default function extractScale<Output extends Value>(
value: number | string | boolean | null | undefined | Date,
) => Output,
scale,
scaleTypeCategory: getScaleTypeCategory(scaleType),
setDomain,
};
}

View File

@ -1,3 +1,5 @@
/** See https://vega.github.io/vega-lite/docs/axis.html */
import { DateTime } from 'vega-lite/build/src/datetime';
export type AxisOrient = 'top' | 'bottom' | 'left' | 'right';
@ -7,6 +9,15 @@ export type LabelOverlapStrategy = 'auto' | 'flat' | 'rotate';
export interface CoreAxis {
format?: string;
labelAngle: number;
/**
* Indicates if the first and last axis labels should be aligned flush with the scale range.
* Flush alignment for a horizontal axis will left-align the first label and right-align the last label.
* For vertical axes, bottom and top text baselines are applied instead.
* If this property is a number, it also indicates the number of pixels by which to offset the first and last labels;
* for example, a value of 2 will flush-align the first and last labels
* and also push them 2 pixels outward from the center of the axis.
* The additional adjustment can sometimes help the labels better visually group with corresponding axis ticks. */
labelFlush?: boolean | number;
labelOverlap: LabelOverlapStrategy;
/** The padding, in pixels, between axis and text labels. */
labelPadding: number;

View File

@ -8,11 +8,11 @@ import { Margin, mergeMargin, Dimension } from '@superset-ui/dimension';
import { ChartFrame } from '@superset-ui/chart-composition';
import createTickComponent from './createTickComponent';
import ChannelEncoder from '../encodeable/ChannelEncoder';
import { AxisOrient } from '../encodeable/types/Axis';
import { XFieldDef, YFieldDef } from '../encodeable/types/ChannelDef';
import { PlainObject } from '../encodeable/types/Data';
import { DEFAULT_LABEL_ANGLE } from './constants';
import convertScaleToDataUIScale from './convertScaleToDataUIScaleShape';
import { AxisLayout } from '../encodeable/AxisAgent';
// Additional margin to avoid content hidden behind scroll bar
const OVERFLOW_MARGIN = 8;
@ -37,20 +37,9 @@ export default class XYChartLayout {
margin: Margin;
config: XYChartLayoutConfig;
xLayout?: {
labelOffset: number;
labelOverlap: string;
labelAngle: number;
tickTextAnchor?: string;
minMargin: Partial<Margin>;
orient: AxisOrient;
};
xLayout?: AxisLayout;
yLayout?: {
labelOffset: number;
minMargin: Partial<Margin>;
orient: AxisOrient;
};
yLayout?: AxisLayout;
// eslint-disable-next-line complexity
constructor(config: XYChartLayoutConfig) {

View File

@ -1,18 +1,19 @@
/* eslint-disable no-magic-numbers */
import React, { CSSProperties } from 'react';
import { Dimension } from '@superset-ui/dimension';
import { AxisLayout } from '../encodeable/AxisAgent';
export default function createTickComponent({
axisWidth,
labelAngle,
labelFlush,
labelOverlap,
orient,
tickTextAnchor = 'start',
}: {
labelAngle: number;
labelOverlap: string;
orient: string;
tickTextAnchor?: string;
}) {
tickLabels,
tickLabelDimensions,
tickTextAnchor = 'middle',
}: AxisLayout) {
if (labelOverlap === 'rotate' && labelAngle !== 0) {
let xOffset = labelAngle > 0 ? -6 : 6;
if (orient === 'top') {
@ -20,7 +21,7 @@ export default function createTickComponent({
}
const yOffset = orient === 'top' ? -3 : 0;
const TickComponent = ({
return ({
x,
y,
dy,
@ -39,8 +40,50 @@ export default function createTickComponent({
</text>
</g>
);
}
return TickComponent;
if (labelFlush === true || typeof labelFlush === 'number') {
const labelToDimensionMap = new Map<string, Dimension>();
tickLabels.forEach((label, i) => {
labelToDimensionMap.set(label, tickLabelDimensions[i]);
});
return ({
x,
y,
dy,
formattedValue = '',
...textStyle
}: {
x: number;
y: number;
dy?: number;
formattedValue: string;
textStyle: CSSProperties;
}) => {
const dimension = labelToDimensionMap.get(formattedValue);
const labelWidth = typeof dimension === 'undefined' ? 0 : dimension.width;
let textAnchor = tickTextAnchor;
let xOffset = 0;
if (x - labelWidth / 2 < 0) {
textAnchor = 'start';
if (typeof labelFlush === 'number') {
xOffset -= labelFlush;
}
} else if (x + labelWidth / 2 > axisWidth) {
textAnchor = 'end';
if (typeof labelFlush === 'number') {
xOffset += labelFlush;
}
}
return (
<text x={x + xOffset} y={y} {...textStyle} textAnchor={textAnchor}>
{formattedValue}
</text>
);
};
}
// This will render the tick as horizontal string.