From 47d4b04836d4defc2878650cee03ea9a66829b4c Mon Sep 17 00:00:00 2001 From: Jukka Kurkela Date: Sun, 11 Jul 2021 13:23:42 +0300 Subject: [PATCH] Layout: support box stacking (#9364) * Layout: support box stacking * Add stackWeight and sample * Cleanup, update docs and types * Avoid div0 * missing semi --- docs/.vuepress/config.js | 1 + docs/axes/cartesian/_common.md | 2 + docs/samples/scales/stacked.md | 71 ++++++++++ src/core/core.layouts.js | 133 +++++++++++++------ test/fixtures/core.layouts/stacked-boxes.js | 106 +++++++++++++++ test/fixtures/core.layouts/stacked-boxes.png | Bin 0 -> 17459 bytes types/index.esm.d.ts | 12 ++ 7 files changed, 283 insertions(+), 42 deletions(-) create mode 100644 docs/samples/scales/stacked.md create mode 100644 test/fixtures/core.layouts/stacked-boxes.js create mode 100644 test/fixtures/core.layouts/stacked-boxes.png diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 3a635348f..f3867aa56 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -189,6 +189,7 @@ module.exports = { 'scales/time-line', 'scales/time-max-span', 'scales/time-combo', + 'scales/stacked' ] }, { diff --git a/docs/axes/cartesian/_common.md b/docs/axes/cartesian/_common.md index 96e93a1c9..a9082b55f 100644 --- a/docs/axes/cartesian/_common.md +++ b/docs/axes/cartesian/_common.md @@ -6,6 +6,8 @@ Namespace: `options.scales[scaleId]` | ---- | ---- | ------- | ----------- | `bounds` | `string` | `'ticks'` | Determines the scale bounds. [more...](./index.md#scale-bounds) | `position` | `string` | | Position of the axis. [more...](./index.md#axis-position) +| `stack` | `string` | | Stack group. Axes at the same `position` with same `stack` are stacked. +| `stackWeight` | `number` | 1 | Weight of the scale in stack group. Used to determine the amount of allocated space for the scale within the group. | `axis` | `string` | | Which type of axis this is. Possible values are: `'x'`, `'y'`. If not set, this is inferred from the first character of the ID which should be `'x'` or `'y'`. | `offset` | `boolean` | `false` | If true, extra space is added to the both edges and the axis is scaled to fit into the chart area. This is set to `true` for a bar chart by default. | `title` | `object` | | Scale title configuration. [more...](../labelling.md#scale-title-configuration) diff --git a/docs/samples/scales/stacked.md b/docs/samples/scales/stacked.md new file mode 100644 index 000000000..f6081ab6c --- /dev/null +++ b/docs/samples/scales/stacked.md @@ -0,0 +1,71 @@ +# Stacked Linear / Category + +```js chart-editor +// +const DATA_COUNT = 7; +const NUMBER_CFG = {count: DATA_COUNT, min: 0, max: 100}; + +const labels = Utils.months({count: 7}); +const data = { + labels: labels, + datasets: [ + { + label: 'Dataset 1', + data: [10, 30, 50, 20, 25, 44, -10], + borderColor: Utils.CHART_COLORS.red, + backgroundColor: Utils.CHART_COLORS.red, + }, + { + label: 'Dataset 2', + data: ['ON', 'ON', 'OFF', 'ON', 'OFF', 'OFF', 'ON'], + borderColor: Utils.CHART_COLORS.blue, + backgroundColor: Utils.CHART_COLORS.blue, + stepped: true, + yAxisID: 'y2', + } + ] +}; +// + +// +const config = { + type: 'line', + data: data, + options: { + responsive: true, + plugins: { + title: { + display: true, + text: 'Stacked scales', + }, + }, + scales: { + y: { + type: 'linear', + position: 'left', + stack: 'demo', + stackWeight: 2, + grid: { + borderColor: Utils.CHART_COLORS.red + } + }, + y2: { + type: 'category', + labels: ['ON', 'OFF'], + offset: true, + position: 'left', + stack: 'demo', + stackWeight: 1, + grid: { + borderColor: Utils.CHART_COLORS.blue + } + } + } + }, +}; +// + +module.exports = { + config: config, +}; +``` diff --git a/src/core/core.layouts.js b/src/core/core.layouts.js index 7206db0d9..d5c1e20b3 100644 --- a/src/core/core.layouts.js +++ b/src/core/core.layouts.js @@ -1,5 +1,5 @@ import defaults from './core.defaults'; -import {each, isObject} from '../helpers/helpers.core'; +import {defined, each, isObject} from '../helpers/helpers.core'; import {toPadding} from '../helpers/helpers.options'; /** @@ -28,34 +28,59 @@ function sortByWeight(array, reverse) { function wrapBoxes(boxes) { const layoutBoxes = []; - let i, ilen, box; + let i, ilen, box, pos, stack, stackWeight; for (i = 0, ilen = (boxes || []).length; i < ilen; ++i) { box = boxes[i]; + ({position: pos, options: {stack, stackWeight = 1}} = box); layoutBoxes.push({ index: i, box, - pos: box.position, + pos, horizontal: box.isHorizontal(), - weight: box.weight + weight: box.weight, + stack: stack && (pos + stack), + stackWeight }); } return layoutBoxes; } +function buildStacks(layouts) { + const stacks = {}; + for (const wrap of layouts) { + const {stack, pos, stackWeight} = wrap; + if (!stack || !STATIC_POSITIONS.includes(pos)) { + continue; + } + const _stack = stacks[stack] || (stacks[stack] = {count: 0, placed: 0, weight: 0, size: 0}); + _stack.count++; + _stack.weight += stackWeight; + } + return stacks; +} + +/** + * store dimensions used instead of available chartArea in fitBoxes + **/ function setLayoutDims(layouts, params) { + const stacks = buildStacks(layouts); + const {vBoxMaxWidth, hBoxMaxHeight} = params; let i, ilen, layout; for (i = 0, ilen = layouts.length; i < ilen; ++i) { layout = layouts[i]; - // store dimensions used instead of available chartArea in fitBoxes + const {fullSize} = layout.box; + const stack = stacks[layout.stack]; + const factor = stack && layout.stackWeight / stack.weight; if (layout.horizontal) { - layout.width = layout.box.fullSize && params.availableWidth; - layout.height = params.hBoxMaxHeight; + layout.width = factor ? factor * vBoxMaxWidth : fullSize && params.availableWidth; + layout.height = hBoxMaxHeight; } else { - layout.width = params.vBoxMaxWidth; - layout.height = layout.box.fullSize && params.availableHeight; + layout.width = vBoxMaxWidth; + layout.height = factor ? factor * hBoxMaxHeight : fullSize && params.availableHeight; } } + return stacks; } function buildLayoutBoxes(boxes) { @@ -89,18 +114,20 @@ function updateMaxPadding(maxPadding, boxPadding) { maxPadding.right = Math.max(maxPadding.right, boxPadding.right); } -function updateDims(chartArea, params, layout) { - const box = layout.box; +function updateDims(chartArea, params, layout, stacks) { + const {pos, box} = layout; const maxPadding = chartArea.maxPadding; // dynamically placed boxes size is not considered - if (!isObject(layout.pos)) { + if (!isObject(pos)) { if (layout.size) { // this layout was already counted for, lets first reduce old size - chartArea[layout.pos] -= layout.size; + chartArea[pos] -= layout.size; } - layout.size = layout.horizontal ? box.height : box.width; - chartArea[layout.pos] += layout.size; + const stack = stacks[layout.stack] || {size: 0, count: 1}; + stack.size = Math.max(stack.size, layout.horizontal ? box.height : box.width); + layout.size = stack.size / stack.count; + chartArea[pos] += layout.size; } if (box.getPadding) { @@ -150,7 +177,7 @@ function getMargins(horizontal, chartArea) { : marginForPositions(['top', 'bottom']); } -function fitBoxes(boxes, chartArea, params) { +function fitBoxes(boxes, chartArea, params, stacks) { const refitBoxes = []; let i, ilen, layout, box, refit, changed; @@ -163,7 +190,7 @@ function fitBoxes(boxes, chartArea, params) { layout.height || chartArea.h, getMargins(layout.horizontal, chartArea) ); - const {same, other} = updateDims(chartArea, params, layout); + const {same, other} = updateDims(chartArea, params, layout, stacks); // Dimensions changed and there were non full width boxes before this // -> we have to refit those @@ -177,31 +204,53 @@ function fitBoxes(boxes, chartArea, params) { } } - return refit && fitBoxes(refitBoxes, chartArea, params) || changed; + return refit && fitBoxes(refitBoxes, chartArea, params, stacks) || changed; +} + +function setBoxDims(box, left, top, width, height) { + box.top = top; + box.left = left; + box.right = left + width; + box.bottom = top + height; + box.width = width; + box.height = height; } -function placeBoxes(boxes, chartArea, params) { +function placeBoxes(boxes, chartArea, params, stacks) { const userPadding = params.padding; - let x = chartArea.x; - let y = chartArea.y; - let i, ilen, layout, box; + let {x, y} = chartArea; - for (i = 0, ilen = boxes.length; i < ilen; ++i) { - layout = boxes[i]; - box = layout.box; + for (const layout of boxes) { + const box = layout.box; + const stack = stacks[layout.stack] || {count: 1, placed: 0, weight: 1}; + const weight = (stack.weight * layout.stackWeight) || 1; if (layout.horizontal) { - box.left = box.fullSize ? userPadding.left : chartArea.left; - box.right = box.fullSize ? params.outerWidth - userPadding.right : chartArea.left + chartArea.w; - box.top = y; - box.bottom = y + box.height; - box.width = box.right - box.left; + const width = chartArea.w / weight; + const height = stack.size || box.height; + if (defined(stack.start)) { + y = stack.start; + } + if (box.fullSize) { + setBoxDims(box, userPadding.left, y, params.outerWidth - userPadding.right - userPadding.left, height); + } else { + setBoxDims(box, chartArea.left + stack.placed, y, width, height); + } + stack.start = y; + stack.placed += width; y = box.bottom; } else { - box.left = x; - box.right = x + box.width; - box.top = box.fullSize ? userPadding.top : chartArea.top; - box.bottom = box.fullSize ? params.outerHeight - userPadding.bottom : chartArea.top + chartArea.h; - box.height = box.bottom - box.top; + const height = chartArea.h / weight; + const width = stack.size || box.width; + if (defined(stack.start)) { + x = stack.start; + } + if (box.fullSize) { + setBoxDims(box, x, userPadding.top, width, params.outerHeight - userPadding.bottom - userPadding.top); + } else { + setBoxDims(box, x, chartArea.top + stack.placed, width, height); + } + stack.start = x; + stack.placed += height; x = box.right; } } @@ -372,30 +421,30 @@ export default { y: padding.top }, padding); - setLayoutDims(verticalBoxes.concat(horizontalBoxes), params); + const stacks = setLayoutDims(verticalBoxes.concat(horizontalBoxes), params); // First fit the fullSize boxes, to reduce probability of re-fitting. - fitBoxes(boxes.fullSize, chartArea, params); + fitBoxes(boxes.fullSize, chartArea, params, stacks); // Then fit vertical boxes - fitBoxes(verticalBoxes, chartArea, params); + fitBoxes(verticalBoxes, chartArea, params, stacks); // Then fit horizontal boxes - if (fitBoxes(horizontalBoxes, chartArea, params)) { + if (fitBoxes(horizontalBoxes, chartArea, params, stacks)) { // if the area changed, re-fit vertical boxes - fitBoxes(verticalBoxes, chartArea, params); + fitBoxes(verticalBoxes, chartArea, params, stacks); } handleMaxPadding(chartArea); // Finally place the boxes to correct coordinates - placeBoxes(boxes.leftAndTop, chartArea, params); + placeBoxes(boxes.leftAndTop, chartArea, params, stacks); // Move to opposite side of chart chartArea.x += chartArea.w; chartArea.y += chartArea.h; - placeBoxes(boxes.rightAndBottom, chartArea, params); + placeBoxes(boxes.rightAndBottom, chartArea, params, stacks); chart.chartArea = { left: chartArea.left, diff --git a/test/fixtures/core.layouts/stacked-boxes.js b/test/fixtures/core.layouts/stacked-boxes.js new file mode 100644 index 000000000..e4fb345c5 --- /dev/null +++ b/test/fixtures/core.layouts/stacked-boxes.js @@ -0,0 +1,106 @@ +module.exports = { + config: { + type: 'line', + data: { + datasets: [ + {data: [{x: 1, y: 1}, {x: 2, y: 2}, {x: 3, y: 3}], borderColor: 'red'}, + {data: [{x: 1, y: 1}, {x: 2, y: 2}, {x: 3, y: 3}], yAxisID: 'y1', xAxisID: 'x1', borderColor: 'green'}, + {data: [{x: 1, y: 1}, {x: 2, y: 2}, {x: 3, y: 3}], yAxisID: 'y2', xAxisID: 'x2', borderColor: 'blue'}, + ], + labels: ['tick1', 'tick2', 'tick3'] + }, + options: { + plugins: false, + scales: { + x: { + type: 'linear', + position: 'bottom', + stack: '1', + offset: true, + bounds: 'data', + grid: { + borderColor: 'red' + }, + ticks: { + autoSkip: false, + maxRotation: 0, + count: 3 + } + }, + x1: { + type: 'linear', + position: 'bottom', + stack: '1', + offset: true, + bounds: 'data', + grid: { + borderColor: 'green' + }, + ticks: { + autoSkip: false, + maxRotation: 0, + count: 3 + } + }, + x2: { + type: 'linear', + position: 'bottom', + stack: '1', + offset: true, + bounds: 'data', + grid: { + borderColor: 'blue' + }, + ticks: { + autoSkip: false, + maxRotation: 0, + count: 3 + } + }, + y: { + type: 'linear', + position: 'left', + stack: '1', + offset: true, + grid: { + borderColor: 'red' + }, + ticks: { + precision: 0 + } + }, + y1: { + type: 'linear', + position: 'left', + stack: '1', + offset: true, + grid: { + borderColor: 'green' + }, + ticks: { + precision: 0 + } + }, + y2: { + type: 'linear', + position: 'left', + stack: '1', + offset: true, + grid: { + borderColor: 'blue' + }, + ticks: { + precision: 0 + } + } + } + } + }, + options: { + spriteText: true, + canvas: { + height: 384, + width: 384 + } + } +}; diff --git a/test/fixtures/core.layouts/stacked-boxes.png b/test/fixtures/core.layouts/stacked-boxes.png new file mode 100644 index 0000000000000000000000000000000000000000..e92cea31244740478c475554e90b28837aa5c286 GIT binary patch literal 17459 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