billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
337 lines (266 loc) • 9.72 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
import {TYPE, TYPE_BY_CATEGORY} from "../../config/const";
import {brushEmpty, getBrushSelection, getMinMax, isDefined, notEmpty, isValue, isObject, isNumber, diffDomain, parseDate, sortValue} from "../../module/util";
export default {
getYDomainMinMax(targets, type: "min" | "max"): number | Date | undefined {
const $$ = this;
const {axis, config} = $$;
const isMin = type === "min";
const dataGroups = config.data_groups;
const ids = $$.mapToIds(targets);
const ys = $$.getValuesAsIdKeyed(targets);
if (dataGroups.length > 0) {
const hasValue = $$[`has${isMin ? "Negative" : "Positive"}ValueInTargets`](targets);
for (let j = 0, idsInGroup; (idsInGroup = dataGroups[j]); j++) {
// Determine baseId
idsInGroup = idsInGroup.filter(v => ids.indexOf(v) >= 0);
if (idsInGroup.length === 0) {
continue;
}
const baseId = idsInGroup[0];
const baseAxisId = axis.getId(baseId);
// Initialize base value. Set to 0 if not match with the condition
if (hasValue && ys[baseId]) {
ys[baseId] = ys[baseId].map(v => (
(isMin ? v < 0 : v > 0) ? v : 0
));
}
for (let k = 1, id; (id = idsInGroup[k]); k++) {
if (!ys[id]) {
continue;
}
const axisId = axis.getId(id);
ys[id].forEach((v, i) => {
const val = +v;
const meetCondition = isMin ? val > 0 : val < 0;
if (axisId === baseAxisId && !(hasValue && meetCondition)) {
ys[baseId][i] += val;
}
});
}
}
}
return getMinMax(type, Object.keys(ys).map(key => getMinMax(type, ys[key])));
},
getYDomainMin(targets): number {
return this.getYDomainMinMax(targets, "min");
},
getYDomainMax(targets): number {
return this.getYDomainMinMax(targets, "max");
},
/**
* Check if hidden targets bound to the given axis id
* @param {string} id ID to be checked
* @returns {boolean}
* @private
*/
isHiddenTargetWithYDomain(id): boolean {
const $$ = this;
return $$.state.hiddenTargetIds
.some(v => $$.axis.getId(v) === id);
},
getYDomain(targets, axisId: string, xDomain) {
const $$ = this;
const {axis, config, scale} = $$;
const pfx = `axis_${axisId}`;
if ($$.isStackNormalized()) {
return [0, 100];
}
const targetsByAxisId = targets.filter(t => axis.getId(t.id) === axisId);
const yTargets = xDomain ? $$.filterByXDomain(targetsByAxisId, xDomain) : targetsByAxisId;
if (yTargets.length === 0) { // use domain of the other axis if target of axisId is none
if ($$.isHiddenTargetWithYDomain(axisId)) {
return scale[axisId].domain();
} else {
return axisId === "y2" ?
scale.y.domain() :
// When all data bounds to y2, y Axis domain is called prior y2.
// So, it needs to call to get y2 domain here
$$.getYDomain(targets, "y2", xDomain);
}
}
const yMin = config[`${pfx}_min`];
const yMax = config[`${pfx}_max`];
let yDomainMin = $$.getYDomainMin(yTargets);
let yDomainMax = $$.getYDomainMax(yTargets);
const center = config[`${pfx}_center`];
let isZeroBased = [TYPE.BAR, TYPE.BUBBLE, TYPE.SCATTER, ...TYPE_BY_CATEGORY.Line]
.some(v => {
const type = v.indexOf("area") > -1 ? "area" : v;
return $$.hasType(v, yTargets) && config[`${type}_zerobased`];
});
const isInverted = config[`${pfx}_inverted`];
const showHorizontalDataLabel = $$.hasDataLabel() && config.axis_rotated;
const showVerticalDataLabel = $$.hasDataLabel() && !config.axis_rotated;
// MEMO: avoid inverting domain unexpectedly
yDomainMin = isValue(yMin) ? yMin :
(isValue(yMax) ? (yDomainMin < yMax ? yDomainMin : yMax - 10) : yDomainMin);
yDomainMax = isValue(yMax) ? yMax :
(isValue(yMin) ? (yMin < yDomainMax ? yDomainMax : yMin + 10) : yDomainMax);
if (isNaN(yDomainMin)) { // set minimum to zero when not number
yDomainMin = 0;
}
if (isNaN(yDomainMax)) { // set maximum to have same value as yDomainMin
yDomainMax = yDomainMin;
}
if (yDomainMin === yDomainMax) {
yDomainMin < 0 ? yDomainMax = 0 : yDomainMin = 0;
}
const isAllPositive = yDomainMin >= 0 && yDomainMax >= 0;
const isAllNegative = yDomainMin <= 0 && yDomainMax <= 0;
// Cancel zerobased if axis_*_min / axis_*_max specified
if ((isValue(yMin) && isAllPositive) || (isValue(yMax) && isAllNegative)) {
isZeroBased = false;
}
// Bar/Area chart should be 0-based if all positive|negative
if (isZeroBased) {
isAllPositive && (yDomainMin = 0);
isAllNegative && (yDomainMax = 0);
}
const domainLength = Math.abs(yDomainMax - yDomainMin);
const padding = {top: domainLength * 0.1, bottom: domainLength * 0.1};
if (isDefined(center)) {
const yDomainAbs = Math.max(Math.abs(yDomainMin), Math.abs(yDomainMax));
yDomainMax = center + yDomainAbs;
yDomainMin = center - yDomainAbs;
}
// add padding for data label
if (showHorizontalDataLabel) {
const diff = diffDomain(scale.y.range());
const ratio = $$.getDataLabelLength(yDomainMin, yDomainMax, "width")
.map(v => v / diff);
["bottom", "top"].forEach((v, i) => {
padding[v] += domainLength * (ratio[i] / (1 - ratio[0] - ratio[1]));
});
} else if (showVerticalDataLabel) {
const lengths = $$.getDataLabelLength(yDomainMin, yDomainMax, "height");
["bottom", "top"].forEach((v, i) => {
padding[v] += axis.convertPixelsToAxisPadding(lengths[i], domainLength);
});
}
// if padding is set, the domain will be updated relative the current domain value
// ex) $$.height=300, padding.top=150, domainLength=4 --> domain=6
const p = config[`${pfx}_padding`];
if (notEmpty(p)) {
["bottom", "top"].forEach(v => {
padding[v] = axis.getPadding(p, v, padding[v], domainLength);
});
}
// Bar/Area chart should be 0-based if all positive|negative
if (isZeroBased) {
isAllPositive && (padding.bottom = yDomainMin);
isAllNegative && (padding.top = -yDomainMax);
}
const domain = [yDomainMin - padding.bottom, yDomainMax + padding.top];
return isInverted ? domain.reverse() : domain;
},
getXDomainMinMax(targets, type) {
const $$ = this;
const configValue = $$.config[`axis_x_${type}`];
const dataValue = getMinMax(type, targets.map(t => getMinMax(type, t.values.map(v => v.x))));
let value = isObject(configValue) ? configValue.value : configValue;
value = isDefined(value) && $$.axis.isTimeSeries() ? parseDate(value) : value;
if (isObject(configValue) && configValue.fit && (
(type === "min" && value < dataValue) || (type === "max" && value > dataValue)
)) {
value = undefined;
}
return isDefined(value) ? value : dataValue;
},
getXDomainMin(targets) {
return this.getXDomainMinMax(targets, "min");
},
getXDomainMax(targets) {
return this.getXDomainMinMax(targets, "max");
},
getXDomainPadding(domain) {
const $$ = this;
const {axis, config} = $$;
const diff = domain[1] - domain[0];
const xPadding = config.axis_x_padding;
let maxDataCount;
let padding;
if (axis.isCategorized()) {
padding = 0;
} else if ($$.hasType("bar")) {
maxDataCount = $$.getMaxDataCount();
padding = maxDataCount > 1 ? (diff / (maxDataCount - 1)) / 2 : 0.5;
} else {
padding = diff * 0.01;
}
let left = padding;
let right = padding;
if (isObject(xPadding) && notEmpty(xPadding)) {
left = isValue(xPadding.left) ? xPadding.left : padding;
right = isValue(xPadding.right) ? xPadding.right : padding;
} else if (isNumber(config.axis_x_padding)) {
left = xPadding;
right = xPadding;
}
return {left, right};
},
getXDomain(targets) {
const $$ = this;
const isCategorized = $$.axis.isCategorized();
const isTimeSeries = $$.axis.isTimeSeries();
const xDomain = [$$.getXDomainMin(targets), $$.getXDomainMax(targets)];
const padding = $$.getXDomainPadding(xDomain);
let [firstX, lastX] = xDomain;
let min: Date | number = 0;
let max: Date | number = 0;
// show center of x domain if min and max are the same
if ((firstX - lastX) === 0 && !isCategorized) {
if (isTimeSeries) {
firstX = new Date(firstX.getTime() * 0.5);
lastX = new Date(lastX.getTime() * 1.5);
} else {
firstX = firstX === 0 ? 1 : (firstX * 0.5);
lastX = lastX === 0 ? -1 : (lastX * 1.5);
}
}
if (firstX || firstX === 0) {
min = isTimeSeries ? new Date(firstX.getTime() - padding.left) : firstX - padding.left;
}
if (lastX || lastX === 0) {
max = isTimeSeries ? new Date(lastX.getTime() + padding.right) : lastX + padding.right;
}
return [min, max];
},
updateXDomain(targets, withUpdateXDomain, withUpdateOrgXDomain, withTrim, domain) {
const $$ = this;
const {config, org, scale: {x, subX}} = $$;
const zoomEnabled = config.zoom_enabled;
if (withUpdateOrgXDomain) {
x.domain(domain || sortValue($$.getXDomain(targets)));
org.xDomain = x.domain();
zoomEnabled && $$.zoom.updateScaleExtent();
subX.domain(x.domain());
$$.brush && $$.brush.scale(subX);
}
if (withUpdateXDomain) {
const domainValue = domain || (!$$.brush || brushEmpty($$)) ?
org.xDomain : getBrushSelection($$).map(subX.invert);
x.domain(domainValue);
zoomEnabled && $$.zoom.updateScaleExtent();
}
// Trim domain when too big by zoom mousemove event
withTrim && x.domain($$.trimXDomain(x.orgDomain()));
return x.domain();
},
trimXDomain(domain) {
const zoomDomain = this.getZoomDomain();
const [min, max] = zoomDomain;
if (domain[0] <= min) {
domain[1] = +domain[1] + (min - domain[0]);
domain[0] = min;
}
if (max <= domain[1]) {
domain[0] = +domain[0] - (domain[1] - max);
domain[1] = max;
}
return domain;
},
};