Implements Box Plot and XY Chart utilities (+10 squashed commits) Squashed commits: [878ed06] clean up some more [624bb7f] separate legend and chart [dfef0fa] working version [aaef60d] allow overflow [b696917] fix margins [2e12359] support top,left,bottom,right axes [278607b] create many utilities [88315c1] Many enhancements to BoxPlot [9d8eb80] box plot working [12d0d1e] new box plot working

This commit is contained in:
Krist Wongsuphasawat 2019-02-15 13:15:36 -08:00 committed by Yongjie Zhao
parent b2f523fde1
commit 423edbe558
25 changed files with 1386 additions and 9 deletions

View File

@ -37,11 +37,12 @@
"license": "Apache-2.0",
"devDependencies": {
"@data-ui/build-config": "^0.0.40",
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/time-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x",
"@superset-ui/chart": "^0.9.6",
"@superset-ui/color": "^0.9.5",
"@superset-ui/connection": "^0.9.5",
"@superset-ui/number-format": "^0.9.5",
"@superset-ui/time-format": "^0.9.5",
"@superset-ui/translation": "^0.9.5",
"fast-glob": "^2.2.6",
"fs-extra": "^7.0.1",
"husky": "^1.1.2",

View File

@ -0,0 +1,29 @@
/* eslint-disable no-magic-numbers */
import React from 'react';
import { SuperChart } from '@superset-ui/chart';
import data from './data';
export default [
{
renderStory: () => (
<SuperChart
chartType="box-plot2"
chartProps={{
datasource: { verboseMap: {} },
formData: {
colorScheme: 'd3Category10',
groupby: ['region'],
metrics: ['sum__SP_POP_TOTL'],
vizType: 'box_plot',
whiskerOptions: 'Min/max (no outliers)',
},
height: 400,
payload: { data },
width: 400,
}}
/>
),
storyName: 'Basic',
storyPath: 'plugin-chart-box-plot|BoxPlotChartPlugin',
},
];

View File

@ -0,0 +1,80 @@
/* eslint-disable sort-keys, no-magic-numbers */
export default [
{
label: 'East Asia & Pacific',
values: {
Q1: 1384725172.5,
Q2: 1717904169.0,
Q3: 2032724922.5,
whisker_high: 2240687901.0,
whisker_low: 1031863394.0,
outliers: [],
},
},
{
label: 'Europe & Central Asia',
values: {
Q1: 751386460.5,
Q2: 820716895.0,
Q3: 862814192.5,
whisker_high: 903095786.0,
whisker_low: 660881033.0,
outliers: [],
},
},
{
label: 'Latin America & Caribbean',
values: {
Q1: 313690832.5,
Q2: 421490233.0,
Q3: 529668114.5,
whisker_high: 626270167.0,
whisker_low: 220564224.0,
outliers: [],
},
},
{
label: 'Middle East & North Africa',
values: {
Q1: 152382756.5,
Q2: 232066828.0,
Q3: 318191071.5,
whisker_high: 417451428.0,
whisker_low: 105512645.0,
outliers: [],
},
},
{
label: 'North America',
values: {
Q1: 235506847.5,
Q2: 268896849.0,
Q3: 314553651.5,
whisker_high: 354462656.0,
whisker_low: 198624409.0,
outliers: [],
},
},
{
label: 'South Asia',
values: {
Q1: 772373036.5,
Q2: 1059570231.0,
Q3: 1398841234.0,
whisker_high: 1720976995.0,
whisker_low: 572036107.0,
outliers: [],
},
},
{
label: 'Sub-Saharan Africa',
values: {
Q1: 320037758.0,
Q2: 467337821.0,
Q3: 676768689.0,
whisker_high: 974315323.0,
whisker_low: 228268752.0,
outliers: [],
},
},
];

View File

@ -0,0 +1,8 @@
import BoxPlotChartPlugin from '../../../../superset-ui-legacy-plugin-chart-box-plot/src';
import Stories from './Stories';
new BoxPlotChartPlugin().configure({ key: 'box-plot2' }).register();
export default {
examples: [...Stories],
};

View File

@ -0,0 +1,34 @@
## @superset-ui/legacy-plugin-chart-box-plot
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-box-plot.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-box-plot.svg?style=flat-square)
[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-legacy-plugin-chart-box-plot&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-legacy-plugin-chart-box-plot)
This plugin provides Box Plot for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import BoxPlotChartPlugin from '@superset-ui/legacy-plugin-chart-box-plot';
new BoxPlotChartPlugin()
.configure({ key: 'box-plot' })
.register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-legacy/?selectedKind=plugin-chart-box-plot) for more details.
```js
<SuperChart
chartType="box-plot"
chartProps={{
width: 600,
height: 600,
formData: {...},
payload: {
data: {...},
},
}}
/>
```

View File

@ -0,0 +1,49 @@
{
"name": "@superset-ui/legacy-plugin-chart-box-plot",
"version": "0.0.0",
"description": "Superset Legacy Chart - Box Plot",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"repository": {
"type": "git",
"url": "git+https://github.com/apache-superset/superset-ui-legacy.git"
},
"keywords": [
"superset"
],
"author": "Superset",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache-superset/superset-ui-legacy/issues"
},
"homepage": "https://github.com/apache-superset/superset-ui-legacy#readme",
"publishConfig": {
"access": "public"
},
"private": true,
"dependencies": {
"@data-ui/xy-chart": "^0.0.75",
"@data-ui/theme": "^0.0.75",
"@vx/axis": "^0.0.184",
"@vx/group": "^0.0.183",
"@vx/legend": "^0.0.183",
"@vx/responsive": "^0.0.184",
"@vx/shape": "^0.0.184",
"@vx/scale": "^0.0.182",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"@superset-ui/core": "^0.9.x",
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/dimension": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x",
"react": "^15 || ^16"
}
}

View File

@ -0,0 +1,150 @@
/**
* 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.
*/
/* eslint-disable sort-keys, no-magic-numbers, complexity */
import PropTypes from 'prop-types';
import React from 'react';
import { BoxPlotSeries, XYChart } from '@data-ui/xy-chart';
import { chartTheme } from '@data-ui/theme';
import { CategoricalColorNamespace } from '@superset-ui/color';
import createTooltip from './utils/createBoxPlotTooltip';
import renderLegend from './utils/renderLegend';
import XYChartLayout from './utils/XYChartLayout';
import WithLegend from './WithLegend';
chartTheme.gridStyles.stroke = '#f1f3f5';
const propTypes = {
className: PropTypes.string,
data: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.number),
}),
).isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
margin: PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
bottom: PropTypes.number,
right: PropTypes.number,
}),
encoding: PropTypes.shape({
x: PropTypes.object,
y: PropTypes.object,
color: PropTypes.object,
}).isRequired,
isHorizontal: PropTypes.bool,
theme: PropTypes.object,
};
const defaultProps = {
className: '',
margin: { top: 10, right: 10, left: 10, bottom: 10 },
isHorizontal: false,
theme: chartTheme,
};
class BoxPlot extends React.PureComponent {
renderChart({ width, height }) {
const { data, encoding, margin, theme, isHorizontal } = this.props;
const config = {
width,
height,
minContentWidth: 0,
minContentHeight: 0,
margin,
theme,
encoding: isHorizontal
? {
...encoding,
x: { ...encoding.y, axis: { ...encoding.y.axis, orientation: 'bottom' } },
y: { ...encoding.x, axis: { ...encoding.x.axis, orientation: 'left' } },
}
: encoding,
};
const colorFn = CategoricalColorNamespace.getScale(
encoding.color.scale.scheme,
encoding.color.scale.namespace,
);
const colorField = encoding.color.field;
const children = [
<BoxPlotSeries
key={datum => datum[encoding.x.field]}
animated
data={
isHorizontal
? data.map(row => ({ ...row, y: row[encoding.x.field] }))
: data.map(row => ({ ...row, x: row[encoding.x.field] }))
}
fill={datum => colorFn(datum[colorField])}
fillOpacity={0.4}
stroke={datum => colorFn(datum[colorField])}
strokeWidth={1}
widthRatio={0.6}
horizontal={isHorizontal}
/>,
];
const layout = new XYChartLayout({ ...config, children });
return layout.createChartWithFrame(dim => (
<XYChart
width={dim.width}
height={dim.height}
ariaLabel="BoxPlot"
margin={layout.margin}
renderTooltip={createTooltip(encoding.y.axis.tickFormat)}
showYGrid
theme={config.theme}
xScale={config.encoding.x.scale}
yScale={config.encoding.y.scale}
>
{children}
{layout.createXAxis()}
{layout.createYAxis()}
</XYChart>
));
}
render() {
const { className, data, width, height, encoding } = this.props;
return (
<WithLegend
className={`superset-legacy-chart-box-plot ${className}`}
width={width}
height={height}
position="top"
renderLegend={() => renderLegend(data, encoding.color)}
renderChart={parent => this.renderChart(parent)}
hideLegend={!encoding.color.legend}
/>
);
}
}
BoxPlot.propTypes = propTypes;
BoxPlot.defaultProps = defaultProps;
export default BoxPlot;

View File

@ -0,0 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { isDefined } from '@superset-ui/core';
const propTypes = {
contentHeight: PropTypes.number,
contentWidth: PropTypes.number,
height: PropTypes.number.isRequired,
renderContent: PropTypes.func,
width: PropTypes.number.isRequired,
};
const defaultProps = {
contentHeight: null,
contentWidth: null,
renderContent() {},
};
class ChartFrame extends React.PureComponent {
render() {
const { contentWidth, contentHeight, width, height, renderContent } = this.props;
const overflowX = isDefined(contentWidth) && contentWidth > width;
const overflowY = isDefined(contentHeight) && contentHeight > height;
if (overflowX || overflowY) {
return (
<div
style={{
height,
overflowX: overflowX ? 'scroll' : 'hidden',
overflowY: overflowY ? 'scroll' : 'hidden',
width,
}}
>
{renderContent({
height: contentHeight,
width: contentWidth,
})}
</div>
);
}
return renderContent({ height, width });
}
}
ChartFrame.propTypes = propTypes;
ChartFrame.defaultProps = defaultProps;
export default ChartFrame;

View File

@ -0,0 +1,161 @@
/**
* 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.
*/
/* eslint-disable sort-keys */
import React from 'react';
import PropTypes from 'prop-types';
import { ParentSize } from '@vx/responsive';
const propTypes = {
className: PropTypes.string,
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
legendJustifyContent: PropTypes.oneOf(['center', 'flex-start', 'flex-end']),
position: PropTypes.oneOf(['top', 'left', 'bottom', 'right']),
renderChart: PropTypes.func.isRequired,
renderLegend: PropTypes.func.isRequired,
hideLegend: PropTypes.bool,
};
const defaultProps = {
className: '',
width: 'auto',
height: 'auto',
legendJustifyContent: undefined,
position: 'top',
hideLegend: false,
};
const LEGEND_STYLE_BASE = {
display: 'flex',
flexGrow: 0,
flexShrink: 0,
order: -1,
paddingTop: '5px',
fontSize: '0.9em',
};
const CHART_STYLE_BASE = {
flexGrow: 1,
flexShrink: 1,
flexBasis: 'auto',
position: 'relative',
};
class WithLegend extends React.Component {
getContainerDirection() {
const { position } = this.props;
switch (position) {
case 'left':
return 'row';
case 'right':
return 'row-reverse';
case 'bottom':
return 'column-reverse';
default:
case 'top':
return 'column';
}
}
getLegendJustifyContent() {
const { legendJustifyContent, position } = this.props;
if (legendJustifyContent) {
return legendJustifyContent;
}
switch (position) {
case 'left':
return 'flex-start';
case 'right':
return 'flex-start';
case 'bottom':
return 'flex-end';
default:
case 'top':
return 'flex-end';
}
}
render() {
const {
className,
width,
height,
position,
renderChart,
renderLegend,
hideLegend,
} = this.props;
if (hideLegend) {
return <div className={className}>{renderChart({ width, height })}</div>;
}
const isHorizontal = position === 'left' || position === 'right';
const style = {
display: 'flex',
flexDirection: this.getContainerDirection(),
};
if (width) {
style.width = width;
}
if (height) {
style.height = height;
}
const chartStyle = { ...CHART_STYLE_BASE };
if (isHorizontal) {
chartStyle.width = 0;
} else {
chartStyle.height = 0;
}
const legendDirection = isHorizontal ? 'column' : 'row';
const legendStyle = {
...LEGEND_STYLE_BASE,
flexDirection: legendDirection,
justifyContent: this.getLegendJustifyContent(),
};
return (
<div className={`with-legend ${className}`} style={style}>
<div className="legend-container" style={legendStyle}>
{renderLegend({
// Pass flexDirection for @vx/legend to arrange legend items
direction: legendDirection,
})}
</div>
<div className="main-container" style={chartStyle}>
<ParentSize>
{parent =>
parent.width > 0 && parent.height > 0
? // Only render when necessary
renderChart(parent)
: null
}
</ParentSize>
</div>
</div>
);
}
}
WithLegend.propTypes = propTypes;
WithLegend.defaultProps = defaultProps;
export default WithLegend;

View File

@ -0,0 +1,104 @@
import React from 'react';
import PropTypes from 'prop-types';
import { AxisBottom, AxisTop } from '@vx/axis';
import { axisStylesShape, tickStylesShape } from '@data-ui/xy-chart/esm/utils/propShapes';
const propTypes = {
axisStyles: axisStylesShape,
hideZero: PropTypes.bool,
label: PropTypes.string,
labelOffset: PropTypes.number,
labelProps: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
numTicks: PropTypes.number,
orientation: PropTypes.oneOf(['bottom', 'top']),
rangePadding: PropTypes.number,
tickStyles: tickStylesShape,
tickComponent: PropTypes.func,
tickLabelProps: PropTypes.func,
tickFormat: PropTypes.func,
tickValues: PropTypes.arrayOf(
// number or date/moment object
PropTypes.oneOfType([PropTypes.number, PropTypes.object, PropTypes.string]),
),
// probably injected by parent
innerHeight: PropTypes.number,
scale: PropTypes.func,
};
const defaultProps = {
axisStyles: {},
hideZero: false,
innerHeight: null,
label: null,
labelOffset: 14,
labelProps: null,
numTicks: null,
orientation: 'bottom',
rangePadding: null,
scale: null,
tickComponent: null,
tickFormat: null,
tickLabelProps: null,
tickStyles: {},
tickValues: undefined,
};
export default class XAxis extends React.PureComponent {
render() {
const {
axisStyles,
innerHeight,
hideZero,
label,
labelOffset,
labelProps,
numTicks,
orientation,
rangePadding,
scale,
tickComponent,
tickFormat,
tickLabelProps: passedTickLabelProps,
tickStyles,
tickValues,
} = this.props;
if (!scale || !innerHeight) return null;
const Axis = orientation === 'bottom' ? AxisBottom : AxisTop;
let tickLabelProps = passedTickLabelProps;
if (!tickLabelProps) {
tickLabelProps =
tickStyles.label && tickStyles.label[orientation]
? () => tickStyles.label[orientation]
: undefined;
}
return (
<Axis
top={orientation === 'bottom' ? innerHeight : 0}
left={0}
rangePadding={rangePadding}
hideTicks={numTicks === 0}
hideZero={hideZero}
label={label}
labelOffset={labelOffset}
labelProps={labelProps || (axisStyles.label || {})[orientation]}
numTicks={numTicks}
scale={scale}
stroke={axisStyles.stroke}
strokeWidth={axisStyles.strokeWidth}
tickComponent={tickComponent}
tickFormat={tickFormat}
tickLabelProps={tickLabelProps}
tickLength={tickStyles.tickLength}
tickStroke={tickStyles.stroke}
tickValues={tickValues}
/>
);
}
}
XAxis.propTypes = propTypes;
XAxis.defaultProps = defaultProps;
XAxis.displayName = 'XAxis';

View File

@ -16,7 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
.with-legend .legend-container {
padding-top: 5px;
font-size: 0.9em;
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
description: '',
name: t('Box Plot'),
thumbnail,
useLegacyApi: true,
});
export default class HistogramChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./BoxPlot'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,90 @@
/**
* 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.
*/
/* eslint-disable sort-keys */
import { getNumberFormatter } from '@superset-ui/number-format';
export default function transformProps(chartProps) {
const { width, height, datasource = {}, formData, payload } = chartProps;
const { verboseMap = {} } = datasource;
const { colorScheme, groupby, metrics } = formData;
const data = payload.data.map(({ label, values }) => ({
label,
min: values.whisker_low,
max: values.whisker_high,
firstQuartile: values.Q1,
median: values.Q2,
thirdQuartile: values.Q3,
outliers: values.outliers,
}));
const xAxisLabel = groupby.join('/');
const yAxisLabel = metrics.length > 0 ? verboseMap[metrics[0]] || metrics[0] : '';
const boxPlotValues = data.reduce((r, e) => r.push(e.min, e.max, ...e.outliers) && r, []);
const minBoxPlotValue = Math.min(...boxPlotValues);
const maxBoxPlotValue = Math.max(...boxPlotValues);
const valueDomain = [
minBoxPlotValue - 0.1 * Math.abs(minBoxPlotValue),
maxBoxPlotValue + 0.1 * Math.abs(maxBoxPlotValue),
];
const formatValue = getNumberFormatter();
return {
data,
width,
height,
// margin,
// theme,
encoding: {
x: {
field: 'label',
scale: {
type: 'band',
paddingInner: 0.15,
paddingOuter: 0.3,
},
axis: {
label: xAxisLabel,
orientation: 'bottom',
},
},
y: {
scale: {
type: 'linear',
domain: valueDomain,
},
axis: {
label: yAxisLabel,
numTicks: 5,
orientation: 'left',
tickFormat: formatValue,
},
},
color: {
field: 'label',
scale: {
scheme: colorScheme,
},
legend: false,
},
},
};
}

View File

@ -0,0 +1,141 @@
/* eslint-disable sort-keys, no-magic-numbers */
import React from 'react';
import collectScalesFromProps from '@data-ui/xy-chart/esm/utils/collectScalesFromProps';
import { YAxis } from '@data-ui/xy-chart';
import adjustMargin from './adjustMargin';
import computeXAxisLayout from './computeXAxisLayout';
import computeYAxisLayout from './computeYAxisLayout';
import createTickComponent from './createTickComponent';
import getTickLabels from './getTickLabels';
import XAxis from '../XAxis';
import ChartFrame from '../ChartFrame';
// Additional margin to avoid content hidden behind scroll bar
const OVERFLOW_MARGIN = 8;
export default class XYChartLayout {
constructor(config) {
this.config = config;
const {
width,
height,
minContentWidth = 0,
minContentHeight = 0,
margin,
encoding,
children,
theme,
} = config;
const { x, y } = encoding;
const { xScale, yScale } = collectScalesFromProps({
width,
height,
margin,
xScale: x.scale,
yScale: y.scale,
theme,
children,
});
const { axis: yAxis = {} } = y;
const yLayout = computeYAxisLayout({
orientation: yAxis.orientation,
tickLabels: getTickLabels(yScale, yAxis),
tickLength: theme.yTickStyles.length,
tickTextStyle: theme.yTickStyles.label.right,
});
const secondMargin = adjustMargin(margin, yLayout.minMargin);
const { left, right } = secondMargin;
const { axis: xAxis = {} } = x;
const { orientation: xOrientation = 'bottom' } = xAxis;
const AUTO_ROTATION =
(yLayout.orientation === 'right' && xOrientation === 'bottom') ||
(yLayout.orientation === 'left' && xOrientation === 'top')
? 40
: -40;
const { rotation = AUTO_ROTATION } = xAxis;
const innerWidth = Math.max(width - left - right, minContentWidth);
const xLayout = computeXAxisLayout({
axisWidth: innerWidth,
orientation: xOrientation,
rotation,
tickLabels: getTickLabels(xScale, xAxis),
tickLength: theme.xTickStyles.length,
tickTextStyle: theme.xTickStyles.label.bottom,
});
const finalMargin = adjustMargin(secondMargin, xLayout.minMargin);
const innerHeight = Math.max(height - finalMargin.top - finalMargin.bottom, minContentHeight);
const chartWidth = Math.round(innerWidth + finalMargin.left + finalMargin.right);
const chartHeight = Math.round(innerHeight + finalMargin.top + finalMargin.bottom);
const isOverFlowX = chartWidth > width;
const isOverFlowY = chartHeight > height;
if (isOverFlowX) {
finalMargin.bottom += OVERFLOW_MARGIN;
}
if (isOverFlowY) {
finalMargin.right += OVERFLOW_MARGIN;
}
this.chartWidth = isOverFlowX ? chartWidth + OVERFLOW_MARGIN : chartWidth;
this.chartHeight = isOverFlowY ? chartHeight + OVERFLOW_MARGIN : chartHeight;
this.containerWidth = width;
this.containerHeight = height;
this.margin = finalMargin;
this.xLayout = xLayout;
this.yLayout = yLayout;
}
createChartWithFrame(renderChart) {
return (
<ChartFrame
width={this.containerWidth}
height={this.containerHeight}
containerWidth={this.containerWidth}
contentHeight={this.containerHeight}
renderContent={renderChart}
/>
);
}
createXAxis(props) {
const { axis } = this.config.encoding.x;
return (
<XAxis
label={axis.label}
labelOffset={this.xLayout.labelOffset}
orientation={this.xLayout.orientation}
tickComponent={createTickComponent(this.xLayout)}
tickFormat={axis.tickFormat}
{...props}
/>
);
}
createYAxis(props) {
const { axis } = this.config.encoding.y;
return (
<YAxis
label={axis.label}
labelOffset={this.yLayout.labelOffset}
numTicks={axis.numTicks}
orientation={this.yLayout.orientation}
tickFormat={axis.tickFormat}
{...props}
/>
);
}
}

View File

@ -0,0 +1,16 @@
export default function adjustMargin(baseMargin = {}, minMargin = {}) {
const { top = 0, left = 0, bottom = 0, right = 0 } = baseMargin;
const {
top: minTop = 0,
left: minLeft = 0,
bottom: minBottom = 0,
right: minRight = 0,
} = minMargin;
return {
bottom: Math.max(bottom, minBottom),
left: Math.max(left, minLeft),
right: Math.max(right, minRight),
top: Math.max(top, minTop),
};
}

View File

@ -0,0 +1,148 @@
/* eslint-disable sort-keys, no-magic-numbers */
import React from 'react';
import collectScalesFromProps from '@data-ui/xy-chart/esm/utils/collectScalesFromProps';
import { YAxis } from '@data-ui/xy-chart';
import adjustMargin from './adjustMargin';
import computeXAxisLayout from './computeXAxisLayout';
import computeYAxisLayout from './computeYAxisLayout';
import createTickComponent from './createTickComponent';
import getTickLabels from './getTickLabels';
import XAxis from '../XAxis';
const OVERFLOW_MARGIN = 8;
// {
// width,
// height,
// margin:
// encoding: {
// x: {
// scale:
// axis: {
// labellingStrategy:
// rotation:
// orientation:
// scaleConfig:
// tickFormat:
// tickValues:
// numTicks:
// }
// },
// y: {
// scale:
// axis: {
// tickFormat:
// tickValues:
// numTicks:
// orientation:
// }
// },
// }
// children:
// theme:
// }
export default function computeChartLayout(config) {
const {
width,
height,
minContentWidth = 0,
minContentHeight = 0,
margin,
encoding,
children,
theme,
} = config;
const { x, y } = encoding;
const { xScale, yScale } = collectScalesFromProps({
width,
height,
margin,
xScale: x.scale,
yScale: y.scale,
theme,
children,
});
const { axis: yAxis = {} } = y;
const yLayout = computeYAxisLayout({
orientation: yAxis.orientation,
tickLabels: getTickLabels(yScale, y.axis),
tickLength: theme.xTickStyles.length,
tickTextStyle: theme.yTickStyles.label.right,
});
const secondMargin = adjustMargin(margin, yLayout.minMargin);
const { left, right } = secondMargin;
const innerWidth = Math.max(width - left - right, minContentWidth);
const { axis: xAxis = {} } = x;
const { orientation: xOrientation = 'bottom' } = xAxis;
const AUTO_ROTATION =
(yLayout.orientation === 'right' && xOrientation === 'bottom') ||
(yLayout.orientation === 'left' && xOrientation === 'top')
? 40
: -40;
const { rotation = AUTO_ROTATION } = xAxis;
const xLayout = computeXAxisLayout({
axisWidth: innerWidth,
orientation: xOrientation,
rotation,
tickLabels: getTickLabels(xScale, x.axis),
tickLength: theme.xTickStyles.length,
tickTextStyle: theme.xTickStyles.label.bottom,
});
const finalMargin = adjustMargin(secondMargin, xLayout.minMargin);
const innerHeight = Math.max(height - finalMargin.top - finalMargin.bottom, minContentHeight);
const createXAxis = props => (
<XAxis
label={config.encoding.x.axis.label}
labelOffset={xLayout.labelOffset}
orientation={xLayout.orientation}
tickComponent={createTickComponent(xLayout)}
{...props}
/>
);
const createYAxis = props => (
<YAxis
label={config.encoding.y.axis.label}
labelOffset={yLayout.labelOffset}
numTicks={config.encoding.y.axis.numTicks}
orientation={yLayout.orientation}
tickFormat={config.encoding.y.axis.tickFormat}
{...props}
/>
);
const chartWidth = Math.round(innerWidth + finalMargin.left + finalMargin.right);
const chartHeight = Math.round(innerHeight + finalMargin.top + finalMargin.bottom);
const isOverFlowX = chartWidth > width;
const isOverFlowY = chartHeight > height;
if (isOverFlowX) {
finalMargin.bottom += OVERFLOW_MARGIN;
}
if (isOverFlowY) {
finalMargin.right += OVERFLOW_MARGIN;
}
return {
chartWidth: isOverFlowX ? chartWidth + OVERFLOW_MARGIN : chartWidth,
chartHeight: isOverFlowY ? chartHeight + OVERFLOW_MARGIN : chartHeight,
containerWidth: width,
containerHeight: height,
margin: finalMargin,
x: xLayout,
y: yLayout,
createXAxis,
createYAxis,
};
}

View File

@ -0,0 +1,74 @@
/* eslint-disable no-magic-numbers */
import { getTextDimension } from '@superset-ui/dimension';
export default function computeXAxisLayout({
axisLabelHeight = 20,
axisWidth,
gapBetweenAxisLabelAndBorder = 8,
gapBetweenTickAndTickLabel = 4,
gapBetweenTickLabelsAndAxisLabel = 4,
labellingStrategy = 'auto',
orientation = 'bottom',
rotation = -40,
tickLabels,
tickLength,
tickTextStyle,
}) {
const labelDimensions = tickLabels.map(text =>
getTextDimension({
text,
style: tickTextStyle,
}),
);
const maxWidth = Math.max(...labelDimensions.map(d => d.width));
const widthPerTick = axisWidth / (tickLabels.length + 1);
let finalStrategy;
if (labellingStrategy !== 'auto') {
finalStrategy = labellingStrategy;
} else if (maxWidth <= widthPerTick) {
finalStrategy = 'flat';
} else {
finalStrategy = 'rotate';
}
// TODO: Add other strategies: stagger, chop, wrap.
let layout = { labelOffset: 0 };
if (finalStrategy === 'flat') {
const labelHeight = labelDimensions[0].height;
const labelOffset = labelHeight + gapBetweenTickLabelsAndAxisLabel;
layout = { labelOffset };
} else if (finalStrategy === 'rotate') {
const labelHeight = Math.ceil(Math.abs(maxWidth * Math.sin((rotation * Math.PI) / 180)));
const labelOffset = labelHeight + gapBetweenTickLabelsAndAxisLabel;
const tickTextAnchor =
(orientation === 'top' && rotation > 0) || (orientation === 'bottom' && rotation < 0)
? 'end'
: 'start';
layout = {
labelOffset,
rotation,
tickTextAnchor,
};
}
const { labelOffset } = layout;
return {
...layout,
labellingStrategy: finalStrategy,
minMargin: {
[orientation]: Math.ceil(
tickLength +
gapBetweenTickAndTickLabel +
labelOffset +
axisLabelHeight +
gapBetweenAxisLabelAndBorder +
8,
),
},
orientation,
};
}

View File

@ -0,0 +1,34 @@
/* eslint-disable no-magic-numbers */
import { getTextDimension } from '@superset-ui/dimension';
export default function computeYAxisLayout({
axisLabelHeight = 20,
gapBetweenAxisLabelAndBorder = 8,
gapBetweenTickAndTickLabel = 4,
gapBetweenTickLabelsAndAxisLabel = 4,
orientation = 'left',
tickLabels,
tickLength,
tickTextStyle,
}) {
const labelDimensions = tickLabels.map(text =>
getTextDimension({
text,
style: tickTextStyle,
}),
);
const maxWidth = Math.ceil(Math.max(...labelDimensions.map(d => d.width)));
let labelOffset = Math.ceil(maxWidth + gapBetweenTickLabelsAndAxisLabel + axisLabelHeight);
let margin = tickLength + gapBetweenTickAndTickLabel + labelOffset + gapBetweenAxisLabelAndBorder;
return {
labelOffset: labelOffset,
minMargin: {
[orientation]: margin,
},
orientation,
};
}

View File

@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function createBoxPlotTooltip(formatValue) {
const propTypes = {
color: PropTypes.string,
datum: PropTypes.shape({
firstQuartile: PropTypes.number,
max: PropTypes.number,
median: PropTypes.number,
min: PropTypes.number,
outliers: PropTypes.arrayOf(PropTypes.number),
thirdQuartile: PropTypes.number,
}).isRequired,
};
const defaultProps = {
color: '#222',
};
function BoxPlotTooltip({ datum, color }) {
const { label, min, max, median, firstQuartile, thirdQuartile, outliers } = datum;
return (
<div>
<div>
<strong style={{ color }}>{label}</strong>
</div>
{min && (
<div>
<strong style={{ color }}>Min </strong>
{formatValue(min)}
</div>
)}
{max && (
<div>
<strong style={{ color }}>Max </strong>
{formatValue(max)}
</div>
)}
{median && (
<div>
<strong style={{ color }}>Median </strong>
{formatValue(median)}
</div>
)}
{firstQuartile && (
<div>
<strong style={{ color }}>First quartile </strong>
{formatValue(firstQuartile)}
</div>
)}
{thirdQuartile && (
<div>
<strong style={{ color }}>Third quartile </strong>
{formatValue(thirdQuartile)}
</div>
)}
{outliers && outliers.length > 0 && (
<div>
<strong style={{ color }}># Outliers </strong>
{outliers.length}
</div>
)}
</div>
);
}
BoxPlotTooltip.propTypes = propTypes;
BoxPlotTooltip.defaultProps = defaultProps;
return BoxPlotTooltip;
}

View File

@ -0,0 +1,46 @@
/* eslint-disable no-magic-numbers */
import React from 'react';
import PropTypes from 'prop-types';
export default function createTickComponent({
labellingStrategy,
orientation = 'bottom',
rotation = 40,
tickTextAnchor = 'start',
}) {
if (labellingStrategy === 'rotate' && rotation !== 0) {
let xOffset = rotation > 0 ? -6 : 6;
if (orientation === 'top') {
xOffset = 0;
}
const yOffset = orientation === 'top' ? -3 : 0;
const propTypes = {
dy: PropTypes.number,
formattedValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
};
const defaultProps = {
dy: null,
formattedValue: '',
};
const TickComponent = ({ x, y, dy, formattedValue, ...textStyle }) => (
<g transform={`translate(${x + xOffset}, ${y + yOffset})`}>
<text transform={`rotate(${rotation})`} {...textStyle} textAnchor={tickTextAnchor}>
{formattedValue}
</text>
</g>
);
TickComponent.propTypes = propTypes;
TickComponent.defaultProps = defaultProps;
return TickComponent;
}
// This will render the tick as horizontal string.
return null;
}

View File

@ -0,0 +1,13 @@
/* eslint-disable no-magic-numbers */
import identity from '@vx/axis/build/utils/identity';
export default function getTickLabels(scale, axisConfig) {
const { numTicks = 10, tickValues, tickFormat } = axisConfig;
let values = scale.ticks ? scale.ticks(numTicks) : scale.domain();
if (tickValues) values = tickValues;
let format = scale.tickFormat ? scale.tickFormat() : identity;
if (tickFormat) format = tickFormat;
return values.map(format);
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { CategoricalColorNamespace } from '@superset-ui/color';
import { LegendOrdinal, LegendItem, LegendLabel } from '@vx/legend';
import { scaleOrdinal } from '@vx/scale';
export default function renderLegend(data, colorEncoding) {
const { field, scale } = colorEncoding;
const { scheme, namespace } = scale;
const colorFn = CategoricalColorNamespace.getScale(scheme, namespace);
const keySet = new Set();
data.forEach(d => {
keySet.add(d[field]);
});
const keys = [...keySet.values()];
const colorScale = scaleOrdinal({
domain: keys,
range: keys.map(colorFn),
});
return (
<div
style={{
maxHeight: 100,
overflowY: 'hidden',
paddingLeft: 14,
paddingTop: 6,
position: 'relative',
}}
>
<LegendOrdinal scale={colorScale} labelFormat={label => label}>
{labels => (
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap' }}>
{labels.map((label, i) => {
const size = 8;
return (
<LegendItem
key={`legend-quantile-${i}`}
margin="0 5px"
onClick={event => {
alert(`clicked: ${JSON.stringify(label)}`);
}}
>
<svg width={size} height={size} style={{ display: 'inline-block' }}>
<rect fill={label.value} width={size} height={size} />
</svg>
<LegendLabel align="left" margin="0 0 0 4px">
{label.text}
</LegendLabel>
</LegendItem>
);
})}
</div>
)}
</LegendOrdinal>
</div>
);
}

View File

@ -98,6 +98,7 @@ class CustomHistogram extends React.PureComponent {
normalized={normalized}
binCount={binCount}
binType="numeric"
margin={{ top: 20, right: 20 }}
renderTooltip={({ datum, color }) => (
<div>
<strong style={{ color }}>

View File

@ -20,7 +20,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ParentSize } from '@vx/responsive';
import './WithLegend.css';
const propTypes = {
className: PropTypes.string,
@ -44,6 +43,8 @@ const LEGEND_STYLE_BASE = {
flexGrow: 0,
flexShrink: 0,
order: -1,
paddingTop: '5px',
fontSize: '0.9em',
};
const CHART_STYLE_BASE = {