victory-box-plot
Version:
Box Plot Component for Victory
475 lines (473 loc) • 15.7 kB
JavaScript
import defaults from "lodash/defaults";
import groupBy from "lodash/groupBy";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { Helpers, Scale, Domain, Data, Collection } from "victory-core";
import { min as d3Min, max as d3Max, quantile as d3Quantile } from "victory-vendor/d3-array";
const TYPES = ["max", "min", "median", "q1", "q3"];
const checkProcessedData = data => {
/* check if the data is pre-processed. start by checking that it has
all required quartile attributes. */
const hasQuartileAttributes = data.every(datum => {
return TYPES.every(val => typeof datum[`_${val}`] !== "undefined");
});
if (hasQuartileAttributes) {
// check that the independent variable is distinct
const values = data.map(d => d._x);
if (!uniq(values).length === values.length) {
throw new Error(`
data prop may only take an array of objects with a unique
independent variable. Make sure your x values are distinct.
`);
}
return true;
}
return false;
};
const nanToNull = val => Number.isNaN(val) ? null : val;
const getSummaryStatistics = data => {
const dependentVars = data.map(datum => datum._y);
const quartiles = {
_q1: nanToNull(d3Quantile(dependentVars, 0.25)),
// eslint-disable-line no-magic-numbers
_q3: nanToNull(d3Quantile(dependentVars, 0.75)),
// eslint-disable-line no-magic-numbers
_min: nanToNull(d3Min(dependentVars)),
_median: nanToNull(d3Quantile(dependentVars, 0.5)),
_max: nanToNull(d3Max(dependentVars))
};
return Object.assign({}, data[0], quartiles, {
_y: data[0]._y
});
};
const processData = data => {
/* check if the data is coming in a pre-processed form,
i.e. { x || y, min, max, q1, q3, median }. if not, process it. */
const isProcessed = checkProcessedData(data);
if (!isProcessed) {
// check if the data is coming with x or y values as an array
const arrayX = data.every(datum => Array.isArray(datum._x));
const arrayY = data.every(datum => Array.isArray(datum._y));
const sortKey = "_y";
const groupKey = "_x";
if (arrayX) {
throw new Error(`
data should not be given as in array for x
`);
} else if (arrayY) {
/* generate summary statistics for each datum. to do this, flatten
the depedentVarArray and process each datum separately */
return data.map(datum => {
const dataArray = datum[sortKey].map(d => Object.assign({}, datum, {
[sortKey]: d
}));
const sortedData = orderBy(dataArray, sortKey);
return getSummaryStatistics(sortedData);
});
} else {
/* Group data by independent variable and generate summary statistics for each group */
const groupedData = groupBy(data, groupKey);
return Object.keys(groupedData).map(key => {
const datum = groupedData[key];
const sortedData = orderBy(datum, sortKey);
return getSummaryStatistics(sortedData);
});
}
} else {
return data;
}
};
export const getData = props => {
const accessorTypes = TYPES.concat("x", "y");
const formattedData = Data.formatData(props.data, props, accessorTypes);
return formattedData.length ? processData(formattedData) : [];
};
const reduceDataset = (dataset, props, axis) => {
const minDomain = Domain.getMinFromProps(props, axis);
const maxDomain = Domain.getMaxFromProps(props, axis);
const minData = minDomain !== undefined ? minDomain : dataset.reduce((memo, datum) => {
return memo < datum[`_${axis}`] ? memo : datum[`_${axis}`];
}, Infinity);
const maxData = maxDomain !== undefined ? maxDomain : dataset.reduce((memo, datum) => {
return memo > datum[`_${axis}`] ? memo : datum[`_${axis}`];
}, -Infinity);
return Domain.getDomainFromMinMax(minData, maxData);
};
const getDomainFromMinMaxValues = (dataset, props, axis) => {
const minDomain = Domain.getMinFromProps(props, axis);
const maxDomain = Domain.getMaxFromProps(props, axis);
const minData = minDomain !== undefined ? minDomain : dataset.reduce((memo, datum) => {
return memo < datum._min ? memo : datum._min;
}, Infinity);
const maxData = maxDomain !== undefined ? maxDomain : dataset.reduce((memo, datum) => {
return memo > datum._max ? memo : datum._max;
}, -Infinity);
return Domain.getDomainFromMinMax(minData, maxData);
};
const getDomainFromData = (props, axis) => {
const minDomain = Domain.getMinFromProps(props, axis);
const maxDomain = Domain.getMaxFromProps(props, axis);
const dataset = getData(props);
if (dataset.length < 1) {
return minDomain !== undefined && maxDomain !== undefined ? Domain.getDomainFromMinMax(minDomain, maxDomain) : undefined;
}
return axis === "y" ? getDomainFromMinMaxValues(dataset, props, axis) : reduceDataset(dataset, props, axis);
};
export const getDomain = (props, axis) => {
return Domain.createDomainFunction(getDomainFromData)(props, axis);
};
const getLabelStyle = (props, styleObject, namespace) => {
const component = props[`${namespace}LabelComponent`] || props.labelComponent;
const baseStyle = styleObject[`${namespace}Labels`] || styleObject.labels;
if (!Helpers.isTooltip(component)) {
return baseStyle;
}
const tooltipTheme = props.theme && props.theme.tooltip || {};
return defaults({}, tooltipTheme.style, baseStyle);
};
const getStyles = function (props, styleObject) {
if (styleObject === void 0) {
styleObject = {};
}
if (props.disableInlineStyles) {
return {};
}
const style = props.style || {};
const parentStyles = {
height: "100%",
width: "100%"
};
const labelStyles = defaults({}, style.labels, getLabelStyle(props, styleObject));
const boxStyles = defaults({}, style.boxes, styleObject.boxes);
const whiskerStyles = defaults({}, style.whiskers, styleObject.whiskers);
return {
boxes: boxStyles,
labels: labelStyles,
parent: defaults({}, style.parent, styleObject.parent, parentStyles),
max: defaults({}, style.max, styleObject.max, whiskerStyles),
maxLabels: defaults({}, style.maxLabels, getLabelStyle(props, styleObject, "max"), labelStyles),
median: defaults({}, style.median, styleObject.median, whiskerStyles),
medianLabels: defaults({}, style.medianLabels, getLabelStyle(props, styleObject, "median"), labelStyles),
min: defaults({}, style.min, styleObject.min, whiskerStyles),
minLabels: defaults({}, style.minLabels, getLabelStyle(props, styleObject, "min"), labelStyles),
q1: defaults({}, style.q1, styleObject.q1, boxStyles),
q1Labels: defaults({}, style.q1Labels, getLabelStyle(props, styleObject, "q1"), labelStyles),
q3: defaults({}, style.q3, styleObject.q3, boxStyles),
q3Labels: defaults({}, style.q3Labels, getLabelStyle(props, styleObject, "q3"), labelStyles),
whiskers: whiskerStyles
};
};
const getCalculatedValues = props => {
const {
theme,
horizontal
} = props;
const data = getData(props);
const range = {
x: Helpers.getRange(props, "x"),
y: Helpers.getRange(props, "y")
};
const domain = {
x: getDomain(props, "x"),
y: getDomain(props, "y")
};
const scale = {
x: Scale.getBaseScale(props, "x").domain(domain.x).range(props.horizontal ? range.y : range.x),
y: Scale.getBaseScale(props, "y").domain(domain.y).range(props.horizontal ? range.x : range.y)
};
const defaultStyles = theme && theme.boxplot && theme.boxplot.style ? theme.boxplot.style : {};
const style = getStyles(props, defaultStyles);
const defaultOrientation = props.horizontal ? "top" : "right";
const labelOrientation = props.labelOrientation || defaultOrientation;
const boxWidth = props.boxWidth || 1;
return {
data,
horizontal,
domain,
scale,
style,
labelOrientation,
boxWidth
};
};
const getWhiskerProps = (props, type) => {
const {
horizontal,
style,
boxWidth,
whiskerWidth,
datum,
scale,
index,
disableInlineStyles
} = props;
const {
min,
max,
q1,
q3,
x,
y
} = props.positions;
const boxValue = type === "min" ? q1 : q3;
const whiskerValue = type === "min" ? min : max;
const width = typeof whiskerWidth === "number" ? whiskerWidth : boxWidth;
return {
datum,
index,
scale,
majorWhisker: {
x1: horizontal ? boxValue : x,
y1: horizontal ? y : boxValue,
x2: horizontal ? whiskerValue : x,
y2: horizontal ? y : whiskerValue
},
minorWhisker: {
x1: horizontal ? whiskerValue : x - width / 2,
y1: horizontal ? y - width / 2 : whiskerValue,
x2: horizontal ? whiskerValue : x + width / 2,
y2: horizontal ? y + width / 2 : whiskerValue
},
style: disableInlineStyles ? {} : style[type] || style.whisker,
disableInlineStyles
};
};
const getBoxProps = (props, type) => {
const {
horizontal,
boxWidth,
style,
scale,
datum,
index,
disableInlineStyles
} = props;
const {
median,
q1,
q3,
x,
y
} = props.positions;
const defaultX = type === "q1" ? q1 : median;
const defaultY = type === "q1" ? median : q3;
const defaultWidth = type === "q1" ? median - q1 : q3 - median;
const defaultHeight = type === "q1" ? q1 - median : median - q3;
return {
datum,
scale,
index,
x: horizontal ? defaultX : x - boxWidth / 2,
y: horizontal ? y - boxWidth / 2 : defaultY,
width: horizontal ? defaultWidth : boxWidth,
height: horizontal ? boxWidth : defaultHeight,
style: disableInlineStyles ? {} : style[type] || style.boxes,
disableInlineStyles
};
};
const getMedianProps = props => {
const {
boxWidth,
horizontal,
style,
datum,
scale,
index,
disableInlineStyles
} = props;
const {
median,
x,
y
} = props.positions;
return {
datum,
scale,
index,
x1: horizontal ? median : x - boxWidth / 2,
y1: horizontal ? y - boxWidth / 2 : median,
x2: horizontal ? median : x + boxWidth / 2,
y2: horizontal ? y + boxWidth / 2 : median,
style: disableInlineStyles ? {} : style.median,
disableInlineStyles
};
};
const getText = (props, type) => {
const {
datum,
index,
labels
} = props;
const propName = `${type}Labels`;
const labelProp = props[propName];
if (!labelProp && !labels) {
return null;
} else if (labelProp === true || labels === true) {
const dataName = `_${type}`;
return `${datum[dataName]}`;
}
return Array.isArray(labelProp) ? labelProp[index] : labelProp;
};
const getOrientation = (labelOrientation, type) => typeof labelOrientation === "object" && labelOrientation[type] || labelOrientation;
const getLabelProps = (props, text, type) => {
const {
datum,
positions,
index,
boxWidth,
horizontal,
labelOrientation,
style,
theme,
disableInlineStyles
} = props;
const orientation = getOrientation(labelOrientation, type);
const namespace = `${type}Labels`;
const labelStyle = style[namespace] || style.labels;
const defaultVerticalAnchors = {
top: "end",
bottom: "start",
left: "middle",
right: "middle"
};
const defaultTextAnchors = {
left: "end",
right: "start",
top: "middle",
bottom: "middle"
};
const whiskerWidth = typeof props.whiskerWidth === "number" ? props.whiskerWidth : boxWidth;
const width = type === "min" || type === "max" ? whiskerWidth : boxWidth;
const getOffset = coord => {
const sign = {
x: orientation === "left" ? -1 : 1,
y: orientation === "top" ? -1 : 1
};
return sign[coord] * width / 2 + sign[coord] * (labelStyle.padding || 0);
};
const labelProps = {
text,
datum,
index,
orientation,
style: disableInlineStyles ? {} : labelStyle,
y: horizontal ? positions.y : positions[type],
x: horizontal ? positions[type] : positions.x,
dy: horizontal ? getOffset("y") : 0,
dx: horizontal ? 0 : getOffset("x"),
textAnchor: labelStyle.textAnchor || defaultTextAnchors[orientation],
verticalAnchor: labelStyle.verticalAnchor || defaultVerticalAnchors[orientation],
angle: labelStyle.angle,
horizontal,
disableInlineStyles
};
const component = props[`${type}LabelComponent`];
if (!Helpers.isTooltip(component)) {
return labelProps;
}
const tooltipTheme = theme && theme.tooltip || {};
return defaults({}, labelProps, Helpers.omit(tooltipTheme, ["style"]));
};
const getDataProps = (props, type) => {
if (type === "median") {
return getMedianProps(props);
} else if (type === "min" || type === "max") {
return getWhiskerProps(props, type);
}
return getBoxProps(props, type);
};
// if all data points on an axis are out of bound of the domain, filter out this datum
const isDatumOutOfBounds = (datum, domain) => {
const exists = val => val !== undefined;
const {
_x,
_min,
_max
} = datum;
const minDomainX = Collection.getMinValue(domain.x);
const maxDomainX = Collection.getMaxValue(domain.x);
const minDomainY = Collection.getMinValue(domain.y);
const maxDomainY = Collection.getMaxValue(domain.y);
const underMin = min => val => exists(val) && val < min;
const overMax = max => val => exists(val) && val > max;
const isUnderMinX = underMin(minDomainX);
const isUnderMinY = underMin(minDomainY);
const isOverMaxX = overMax(maxDomainX);
const isOverMaxY = overMax(maxDomainY);
let yOutOfBounds;
let xOutOfBounds;
// if x is out of the bounds of the domain
if (isUnderMinX(_x) || isOverMaxX(_x)) xOutOfBounds = true;
// if min/max are out of the bounds of the domain
if (isUnderMinY(_min) && isUnderMinY(_max) || isOverMaxY(_min) && isOverMaxY(_max)) yOutOfBounds = true;
return yOutOfBounds || xOutOfBounds;
};
export const getBaseProps = (initialProps, fallbackProps) => {
const modifiedProps = Helpers.modifyProps(initialProps, fallbackProps, "boxplot");
const props = Object.assign({}, modifiedProps, getCalculatedValues(modifiedProps));
const {
groupComponent,
width,
height,
padding,
standalone,
theme,
events,
sharedEvents,
scale,
horizontal,
data,
style,
domain,
name
} = props;
const initialChildProps = {
parent: {
domain,
scale,
width,
height,
data,
standalone,
name,
theme,
style: style.parent || {},
padding,
groupComponent,
horizontal
}
};
const boxScale = scale.y;
return data.reduce((acc, datum, index) => {
const eventKey = !Helpers.isNil(datum.eventKey) ? datum.eventKey : index;
if (isDatumOutOfBounds(datum, domain)) return acc;
const positions = {
x: horizontal ? scale.y(datum._y) : scale.x(datum._x),
y: horizontal ? scale.x(datum._x) : scale.y(datum._y),
min: boxScale(datum._min),
max: boxScale(datum._max),
median: boxScale(datum._median),
q1: boxScale(datum._q1),
q3: boxScale(datum._q3)
};
const dataProps = Object.assign({
index,
datum,
positions
}, props);
const dataObj = TYPES.reduce((memo, type) => {
memo[type] = getDataProps(dataProps, type);
return memo;
}, {});
acc[eventKey] = dataObj;
TYPES.forEach(type => {
const labelText = getText(dataProps, type);
const labelProp = props.labels || props[`${type}Labels`];
if (labelText !== null && labelText !== undefined || labelProp && (events || sharedEvents)) {
const target = `${type}Labels`;
acc[eventKey][target] = getLabelProps(Object.assign({}, props, dataProps), labelText, type);
}
});
return acc;
}, initialChildProps);
};