diff --git a/demo/demo.js b/demo/demo.js
index 331973db3..ecebea661 100644
--- a/demo/demo.js
+++ b/demo/demo.js
@@ -1480,6 +1480,31 @@ var demos = {
},
description: "For selection, click data point or drag over data points"
},
+ DataStackNormalized: {
+ options: {
+ data: {
+ x: "x",
+ columns: [
+ ["x", "Item1", "Item2", "Item3", "Item4", "Item5", "Item6", "Item7"],
+ ["data1", 30, 280, 951, 400, 150, 546, 4528],
+ ["data2", 130, 357, 751, 400, 150, 250, 3957],
+ ["data3", 30, 280, 320, 218, 150, 150, 5000]
+ ],
+ type: "bar",
+ groups: [
+ ["data1", "data2", "data3"]
+ ],
+ stack: {
+ normalize: true
+ }
+ },
+ axis: {
+ x: {
+ type: "category"
+ }
+ }
+ }
+ },
OnMinMaxCallback: {
options: {
data: {
diff --git a/spec/internals/data-spec.js b/spec/internals/data-spec.js
index 9654bb6f5..88d7b25ac 100644
--- a/spec/internals/data-spec.js
+++ b/spec/internals/data-spec.js
@@ -1465,4 +1465,75 @@ describe("DATA", () => {
expect(path.split("L").length).to.be.equal(expected.L);
});
});
+
+ describe("data.stack", () => {
+ let chartHeight = 0;
+
+ before(() => {
+ args = {
+ data: {
+ columns: [
+ ["data1", 230, 50, 300],
+ ["data2", 198, 87, 580]
+ ],
+ type: "bar",
+ groups: [
+ ["data1", "data2"]
+ ],
+ stack: {
+ normalize: true
+ }
+ }
+ };
+ });
+
+ it("check for the normalized y axis tick in percentage", () => {
+ const tick = chart.$.main.selectAll(`.${CLASS.axisY} .tick tspan`);
+
+ // check for the y axis to be in percentage
+ tick.each(function (v, i) {
+ expect(this.textContent).to.be.equal(`${i * 10}%`);
+ });
+ });
+
+ it("check for the normalized bar's height", () => {
+ chartHeight = +chart.$.main.selectAll(`.${CLASS.zoomRect}`).attr("height") - 1;
+ const bars = chart.$.bar.bars.nodes().concat();
+
+ bars.splice(0, 3).forEach((v, i) => {
+ expect(v.getBBox().height + bars[i].getBBox().height).to.be.equal(chartHeight);
+ });
+ });
+
+ it("set options data.type='area'", () => {
+ args.data.type = "area";
+ args.data.columns = [
+ ["data1", 200, 387, 123],
+ ["data2", 200, 387, 123]
+ ];
+ });
+
+ it("check for the normalized area's height", () => {
+ let areaHeight = 0;
+
+ chart.$.main.selectAll(`.${CLASS.areas} path`).each(function() {
+ areaHeight += this.getBBox().height;
+ });
+
+ expect(areaHeight).to.be.equal(chartHeight);
+ });
+
+ it("check for the normalized default tooltip", () => {
+ let tooltipValue = 0;
+
+ // show tooltip
+ chart.tooltip.show({index:1});
+
+ chart.$.tooltip.selectAll(".value").each(function() {
+ tooltipValue += parseInt(this.textContent);
+ });
+
+ expect(tooltipValue).to.be.equal(100);
+ });
+ });
});
diff --git a/src/api/api.group.js b/src/api/api.group.js
index 61d67adc8..0813e0a58 100644
--- a/src/api/api.group.js
+++ b/src/api/api.group.js
@@ -12,6 +12,7 @@ extend(Chart.prototype, {
* @instance
* @memberOf Chart
* @param {Array} groups This argument needs to be an Array that includes one or more Array that includes target ids to be grouped.
+ * @return {Array} Grouped data names array
* @example
* // data1 and data2 will be a new group.
* chart.groups([
diff --git a/src/axis/Axis.js b/src/axis/Axis.js
index 00d979a5c..42ef45ec4 100644
--- a/src/axis/Axis.js
+++ b/src/axis/Axis.js
@@ -109,7 +109,9 @@ export default class Axis {
const axis = bbAxis(axisParams)
.scale(scale)
.orient(orient)
- .tickFormat(tickFormat);
+ .tickFormat(
+ tickFormat || ($$.isStackNormalized() && (x => `${x}%`))
+ );
$$.isTimeSeriesY() ?
// https://github.com/d3/d3/blob/master/CHANGES.md#time-intervals-d3-time
diff --git a/src/config/Options.js b/src/config/Options.js
index dd13124fb..ed01b9795 100644
--- a/src/config/Options.js
+++ b/src/config/Options.js
@@ -671,10 +671,48 @@ export default class Options {
* }
*/
data_hide: false,
+
+ /**
+ * Filter values to be shown
+ * The data value is the same as the returned by `.data()`.
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
+ * @name data․filter
+ * @memberOf Options
+ * @type {Function}
+ * @default undefined
+ * @example
+ * data: {
+ * // filter for id value
+ * filter: function(v) {
+ * // v: [{id: "data1", id_org: "data1", values: [
+ * // {x: 0, value: 130, id: "data2", index: 0}, ...]
+ * // }, ...]
+ * return v.id !== "data1";
+ * }
+ */
data_filter: undefined,
/**
- * Set data selection enabled.
+ * Set the stacking to be normalized
+ * - **NOTE:**
+ * - For stacking, '[data.groups](#.data%25E2%2580%25A4groups)' option should be set
+ * - y Axis will be set in percentage value (0 ~ 100%)
+ * - Must have postive values
+ * @name data․stack․normalize
+ * @memberOf Options
+ * @type {Boolean}
+ * @default false
+ * @see {@link https://naver.github.io/billboard.js/demo/#Data.DataStackNormalized|Example}
+ * @example
+ * data: {
+ * stack: {
+ * normalize: true
+ * }
+ * }
+ */
+ data_stack_normalize: false,
+ /**
+ * Set data selection enabled
* If this option is set true, we can select the data points and get/set its state of selection by API (e.g. select, unselect, selected).
* @name data․selection․enabled
* @memberOf Options
diff --git a/src/data/data.js b/src/data/data.js
index 871394a78..3873bb416 100644
--- a/src/data/data.js
+++ b/src/data/data.js
@@ -40,6 +40,12 @@ extend(ChartInternal.prototype, {
return !this.isX(key);
},
+ isStackNormalized() {
+ const config = this.config;
+
+ return config.data_stack_normalize && config.data_groups.length;
+ },
+
getXKey(id) {
const $$ = this;
const config = $$.config;
@@ -276,6 +282,33 @@ extend(ChartInternal.prototype, {
return minMaxData;
},
+ /**
+ * Get sum of data per index
+ * @private
+ * @return {Array}
+ */
+ getTotalPerIndex() {
+ const $$ = this;
+ const cacheKey = "$totalPerIndex";
+ let sum = $$.getCache(cacheKey);
+
+ if ($$.isStackNormalized() && !sum) {
+ sum = [];
+
+ $$.data.targets.forEach(row => {
+ row.values.forEach((v, i) => {
+ if (!sum[i]) {
+ sum[i] = 0;
+ }
+
+ sum[i] += v.value;
+ });
+ });
+ }
+
+ return sum;
+ },
+
/**
* Get total data sum
* @private
@@ -714,5 +747,50 @@ extend(ChartInternal.prototype, {
}
return value[type];
+ },
+
+ /**
+ * Get ratio value
+ * @param {String} type Ratio for given type
+ * @param {Object} d Data value object
+ * @param {Boolean} asPercent Convert the return as percent or not
+ * @return {Number} Ratio value
+ * @private
+ */
+ getRatio(type, d, asPercent) {
+ const $$ = this;
+ const config = $$.config;
+ let ratio = d && (d.ratio || d.value);
+
+ if (type === "arc") {
+ // if has padAngle set, calculate rate based on value
+ if ($$.pie.padAngle()()) {
+ let total = $$.getTotalDataSum();
+
+ if ($$.hiddenTargetIds.length) {
+ total -= d3Sum($$.api.data.values.call($$.api, $$.hiddenTargetIds));
+ }
+
+ ratio = d.value / total;
+
+ // otherwise, based on the rendered angle value
+ } else {
+ ratio = (d.endAngle - d.startAngle) / (
+ Math.PI * ($$.hasType("gauge") && !config.gauge_fullCircle ? 1 : 2)
+ );
+ }
+ } else if (type === "index" && !d.ratio) {
+ const totalPerIndex = this.getTotalPerIndex();
+
+ if (totalPerIndex && d.value) {
+ d.ratio = d.value / totalPerIndex[d.index];
+ }
+
+ ratio = d.ratio;
+ } else if (type === "radar") {
+ ratio = (parseFloat(Math.max(d.value, 0)) / $$.maxValue) * config.radar_size_ratio;
+ }
+
+ return asPercent ? ratio * 100 : ratio;
}
});
diff --git a/src/internals/domain.js b/src/internals/domain.js
index 49e755c3a..7409cc755 100644
--- a/src/internals/domain.js
+++ b/src/internals/domain.js
@@ -80,6 +80,11 @@ extend(ChartInternal.prototype, {
getYDomain(targets, axisId, xDomain) {
const $$ = this;
const config = $$.config;
+
+ if ($$.isStackNormalized()) {
+ return [0, 100];
+ }
+
const targetsByAxisId = targets.filter(t => $$.axis.getId(t.id) === axisId);
const yTargets = xDomain ? $$.filterByXDomain(targetsByAxisId, xDomain) : targetsByAxisId;
const yMin = axisId === "y2" ? config.axis_y2_min : config.axis_y_min;
diff --git a/src/internals/scale.js b/src/internals/scale.js
index 490edfe8a..367d30b30 100644
--- a/src/internals/scale.js
+++ b/src/internals/scale.js
@@ -35,6 +35,14 @@ extend(ChartInternal.prototype, {
);
},
+ /**
+ * Get y Axis scale function
+ * @param {Number} min
+ * @param {Number} max
+ * @param {Number} domain
+ * @return {Function} scale
+ * @private
+ */
getY(min, max, domain) {
const scale = this.getScale(min, max, this.isTimeSeriesY());
diff --git a/src/internals/tooltip.js b/src/internals/tooltip.js
index c73bc0c4a..712d9ba3e 100644
--- a/src/internals/tooltip.js
+++ b/src/internals/tooltip.js
@@ -66,9 +66,8 @@ extend(ChartInternal.prototype, {
const config = $$.config;
const titleFormat = config.tooltip_format_title || defaultTitleFormat;
const nameFormat = config.tooltip_format_name || (name => name);
- const valueFormat = config.tooltip_format_value || defaultValueFormat;
+ const valueFormat = config.tooltip_format_value || ($$.isStackNormalized() ? ((v, ratio) => `${(ratio * 100).toFixed(2)}%`) : defaultValueFormat);
const order = config.tooltip_order;
-
const getRowValue = row => $$.getBaseValue(row);
const getBgColor = $$.levelColor ? row => $$.levelColor(row.value) : row => color(row.id);
diff --git a/src/shape/arc.js b/src/shape/arc.js
index 06a694395..6bcbdc14b 100644
--- a/src/shape/arc.js
+++ b/src/shape/arc.js
@@ -10,7 +10,6 @@ import {
arc as d3Arc,
pie as d3Pie
} from "d3-shape";
-import {sum as d3Sum} from "d3-array";
import {interpolate as d3Interpolate} from "d3-interpolate";
import ChartInternal from "../internals/ChartInternal";
import CLASS from "../config/classes";
@@ -175,38 +174,11 @@ extend(ChartInternal.prototype, {
return translate;
},
- getArcRatio(d) {
- const $$ = this;
- const config = $$.config;
- let val = null;
-
- if (d) {
- // if has padAngle set, calculate rate based on value
- if ($$.pie.padAngle()()) {
- let total = $$.getTotalDataSum();
-
- if ($$.hiddenTargetIds.length) {
- total -= d3Sum($$.api.data.values.call($$.api, $$.hiddenTargetIds));
- }
-
- val = d.value / total;
-
- // otherwise, based on the rendered angle value
- } else {
- val = (d.endAngle - d.startAngle) / (
- Math.PI * ($$.hasType("gauge") && !config.gauge_fullCircle ? 1 : 2)
- );
- }
- }
-
- return val;
- },
-
convertToArcData(d) {
return this.addName({
id: d.data.id,
value: d.value,
- ratio: this.getArcRatio(d),
+ ratio: this.getRatio("arc", d),
index: d.index,
});
},
@@ -221,7 +193,7 @@ extend(ChartInternal.prototype, {
const updated = $$.updateAngle(d);
const value = updated ? updated.value : null;
- const ratio = $$.getArcRatio(updated);
+ const ratio = $$.getRatio("arc", updated);
const id = d.data.id;
if (!$$.hasType("gauge") && !$$.meetsArcLabelThreshold(ratio)) {
diff --git a/src/shape/bar.js b/src/shape/bar.js
index b68966aac..365adf308 100644
--- a/src/shape/bar.js
+++ b/src/shape/bar.js
@@ -173,11 +173,13 @@ extend(ChartInternal.prototype, {
posY = y0;
}
+ posY -= (y0 - offset);
+
// 4 points that make a bar
return [
[posX, offset],
- [posX, posY - (y0 - offset)],
- [posX + barW, posY - (y0 - offset)],
+ [posX, posY],
+ [posX + barW, posY],
[posX + barW, offset]
];
};
diff --git a/src/shape/radar.js b/src/shape/radar.js
index 3c60e6233..cea211466 100644
--- a/src/shape/radar.js
+++ b/src/shape/radar.js
@@ -107,7 +107,6 @@ extend(ChartInternal.prototype, {
*/
generateRadarPoints() {
const $$ = this;
- const config = $$.config;
const targets = $$.data.targets;
const [width, height] = $$.getRadarSize();
@@ -116,11 +115,9 @@ extend(ChartInternal.prototype, {
// recalculate position only when the previous dimension has been changed
if (!size || (size.width !== width && size.height !== height)) {
- const getRatio = v => (parseFloat(Math.max(v, 0)) / $$.maxValue) * config.radar_size_ratio;
-
targets.forEach(d => {
points[d.id] = d.values.map((v, i) => (
- $$.getRadarPosition(["x", "y"], i, undefined, getRatio(v.value))
+ $$.getRadarPosition(["x", "y"], i, undefined, $$.getRatio("radar", v))
));
});
diff --git a/src/shape/shape.js b/src/shape/shape.js
index e69aedaec..3b87e9828 100644
--- a/src/shape/shape.js
+++ b/src/shape/shape.js
@@ -34,7 +34,8 @@ extend(ChartInternal.prototype, {
const indices = {};
let i = 0;
- $$.filterTargetsToShow($$.data.targets.filter(typeFilter, $$))
+ $$.filterTargetsToShow($$.data.targets
+ .filter(typeFilter, $$))
.forEach(d => {
for (let j = 0, groups; (groups = config.data_groups[j]); j++) {
if (groups.indexOf(d.id) < 0) {
@@ -88,12 +89,11 @@ extend(ChartInternal.prototype, {
getShapeY(isSub) {
const $$ = this;
+ const isStackNormalized = $$.isStackNormalized();
- return d => {
- const scale = isSub ? $$.getSubYScale(d.id) : $$.getYScale(d.id);
-
- return scale(d.value);
- };
+ return d => (isSub ? $$.getSubYScale(d.id) : $$.getYScale(d.id))(
+ isStackNormalized ? $$.getRatio("index", d, true) : d.value
+ );
},
getShapeOffset(typeFilter, indices, isSub) {
@@ -107,34 +107,36 @@ extend(ChartInternal.prototype, {
let offset = y0;
let i = idx;
- targets.forEach(t => {
- const values = $$.isStepType(d) ? $$.convertValuesToStep(t.values) : t.values;
+ targets
+ .forEach(t => {
+ const rowValues = $$.isStepType(d) ? $$.convertValuesToStep(t.values) : t.values;
+ const values = rowValues.map(v => ($$.isStackNormalized() ? $$.getRatio("index", v, true) : v.value));
- if (t.id === d.id || indices[t.id] !== indices[d.id]) {
- return;
- }
+ if (t.id === d.id || indices[t.id] !== indices[d.id]) {
+ return;
+ }
- if (targetIds.indexOf(t.id) < targetIds.indexOf(d.id)) {
- // check if the x values line up
- if (isUndefined(values[i]) || +values[i].x !== +d.x) { // "+" for timeseries
- // if not, try to find the value that does line up
- i = -1;
+ if (targetIds.indexOf(t.id) < targetIds.indexOf(d.id)) {
+ // check if the x values line up
+ if (isUndefined(rowValues[i]) || +rowValues[i].x !== +d.x) { // "+" for timeseries
+ // if not, try to find the value that does line up
+ i = -1;
- values.forEach((v, j) => {
- const x1 = v.x.constructor === Date ? +v.x : v.x;
- const x2 = d.x.constructor === Date ? +d.x : d.x;
+ rowValues.forEach((v, j) => {
+ const x1 = v.x.constructor === Date ? +v.x : v.x;
+ const x2 = d.x.constructor === Date ? +d.x : d.x;
- if (x1 === x2) {
- i = j;
- }
- });
- }
+ if (x1 === x2) {
+ i = j;
+ }
+ });
+ }
- if (i in values && values[i].value * d.value >= 0) {
- offset += scale(values[i].value) - y0;
+ if (i in rowValues && rowValues[i].value * d.value >= 0) {
+ offset += scale(values[i]) - y0;
+ }
}
- }
- });
+ });
return offset;
};