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; };