| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| /* global Float32Array */ |
| |
| import * as zrUtil from 'zrender/src/core/util'; |
| import {parsePercent} from '../util/number'; |
| import {isDimensionStacked} from '../data/helper/dataStackHelper'; |
| import createRenderPlanner from '../chart/helper/createRenderPlanner'; |
| import BarSeriesModel from '../chart/bar/BarSeries'; |
| import Axis2D from '../coord/cartesian/Axis2D'; |
| import GlobalModel from '../model/Global'; |
| import type Cartesian2D from '../coord/cartesian/Cartesian2D'; |
| import { StageHandler, Dictionary } from '../util/types'; |
| |
| const STACK_PREFIX = '__ec_stack_'; |
| const LARGE_BAR_MIN_WIDTH = 0.5; |
| |
| const LargeArr = typeof Float32Array !== 'undefined' ? Float32Array : Array; |
| |
| |
| function getSeriesStackId(seriesModel: BarSeriesModel): string { |
| return seriesModel.get('stack') || STACK_PREFIX + seriesModel.seriesIndex; |
| } |
| |
| function getAxisKey(axis: Axis2D): string { |
| return axis.dim + axis.index; |
| } |
| |
| interface LayoutSeriesInfo { |
| bandWidth: number |
| barWidth: number |
| barMaxWidth: number |
| barMinWidth: number |
| barGap: number | string |
| barCategoryGap: number | string |
| axisKey: string |
| stackId: string |
| } |
| |
| interface StackInfo { |
| width: number |
| maxWidth: number |
| minWidth?: number |
| } |
| |
| /** |
| * { |
| * [coordSysId]: { |
| * [stackId]: {bandWidth, offset, width} |
| * } |
| * } |
| */ |
| type BarWidthAndOffset = Dictionary<Dictionary<{ |
| bandWidth: number |
| offset: number |
| offsetCenter: number |
| width: number |
| }>>; |
| |
| export interface BarGridLayoutOptionForCustomSeries { |
| count: number |
| |
| barWidth?: number |
| barMaxWidth?: number |
| barMinWidth?: number |
| barGap?: number |
| barCategoryGap?: number |
| } |
| interface LayoutOption extends BarGridLayoutOptionForCustomSeries { |
| axis: Axis2D |
| } |
| |
| export type BarGridLayoutResult = BarWidthAndOffset[string][string][]; |
| /** |
| * @return {Object} {width, offset, offsetCenter} If axis.type is not 'category', return undefined. |
| */ |
| export function getLayoutOnAxis(opt: LayoutOption): BarGridLayoutResult { |
| const params: LayoutSeriesInfo[] = []; |
| const baseAxis = opt.axis; |
| const axisKey = 'axis0'; |
| |
| if (baseAxis.type !== 'category') { |
| return; |
| } |
| const bandWidth = baseAxis.getBandWidth(); |
| |
| for (let i = 0; i < opt.count || 0; i++) { |
| params.push(zrUtil.defaults({ |
| bandWidth: bandWidth, |
| axisKey: axisKey, |
| stackId: STACK_PREFIX + i |
| }, opt) as LayoutSeriesInfo); |
| } |
| const widthAndOffsets = doCalBarWidthAndOffset(params); |
| |
| const result = []; |
| for (let i = 0; i < opt.count; i++) { |
| const item = widthAndOffsets[axisKey][STACK_PREFIX + i]; |
| item.offsetCenter = item.offset + item.width / 2; |
| result.push(item); |
| } |
| |
| return result; |
| } |
| |
| export function prepareLayoutBarSeries(seriesType: string, ecModel: GlobalModel): BarSeriesModel[] { |
| const seriesModels: BarSeriesModel[] = []; |
| ecModel.eachSeriesByType(seriesType, function (seriesModel: BarSeriesModel) { |
| // Check series coordinate, do layout for cartesian2d only |
| if (isOnCartesian(seriesModel) && !isInLargeMode(seriesModel)) { |
| seriesModels.push(seriesModel); |
| } |
| }); |
| return seriesModels; |
| } |
| |
| |
| /** |
| * Map from (baseAxis.dim + '_' + baseAxis.index) to min gap of two adjacent |
| * values. |
| * This works for time axes, value axes, and log axes. |
| * For a single time axis, return value is in the form like |
| * {'x_0': [1000000]}. |
| * The value of 1000000 is in milliseconds. |
| */ |
| function getValueAxesMinGaps(barSeries: BarSeriesModel[]) { |
| /** |
| * Map from axis.index to values. |
| * For a single time axis, axisValues is in the form like |
| * {'x_0': [1495555200000, 1495641600000, 1495728000000]}. |
| * Items in axisValues[x], e.g. 1495555200000, are time values of all |
| * series. |
| */ |
| const axisValues: Dictionary<number[]> = {}; |
| zrUtil.each(barSeries, function (seriesModel) { |
| const cartesian = seriesModel.coordinateSystem as Cartesian2D; |
| const baseAxis = cartesian.getBaseAxis(); |
| if (baseAxis.type !== 'time' && baseAxis.type !== 'value') { |
| return; |
| } |
| |
| const data = seriesModel.getData(); |
| const key = baseAxis.dim + '_' + baseAxis.index; |
| const dim = data.mapDimension(baseAxis.dim); |
| for (let i = 0, cnt = data.count(); i < cnt; ++i) { |
| const value = data.get(dim, i) as number; |
| if (!axisValues[key]) { |
| // No previous data for the axis |
| axisValues[key] = [value]; |
| } |
| else { |
| // No value in previous series |
| axisValues[key].push(value); |
| } |
| // Ignore duplicated time values in the same axis |
| } |
| }); |
| |
| const axisMinGaps: Dictionary<number> = {}; |
| for (const key in axisValues) { |
| if (axisValues.hasOwnProperty(key)) { |
| const valuesInAxis = axisValues[key]; |
| if (valuesInAxis) { |
| // Sort axis values into ascending order to calculate gaps |
| valuesInAxis.sort(function (a, b) { |
| return a - b; |
| }); |
| |
| let min = null; |
| for (let j = 1; j < valuesInAxis.length; ++j) { |
| const delta = valuesInAxis[j] - valuesInAxis[j - 1]; |
| if (delta > 0) { |
| // Ignore 0 delta because they are of the same axis value |
| min = min === null ? delta : Math.min(min, delta); |
| } |
| } |
| // Set to null if only have one data |
| axisMinGaps[key] = min; |
| } |
| } |
| } |
| return axisMinGaps; |
| } |
| |
| export function makeColumnLayout(barSeries: BarSeriesModel[]) { |
| const axisMinGaps = getValueAxesMinGaps(barSeries); |
| |
| const seriesInfoList: LayoutSeriesInfo[] = []; |
| zrUtil.each(barSeries, function (seriesModel) { |
| const cartesian = seriesModel.coordinateSystem as Cartesian2D; |
| const baseAxis = cartesian.getBaseAxis(); |
| const axisExtent = baseAxis.getExtent(); |
| |
| let bandWidth; |
| if (baseAxis.type === 'category') { |
| bandWidth = baseAxis.getBandWidth(); |
| } |
| else if (baseAxis.type === 'value' || baseAxis.type === 'time') { |
| const key = baseAxis.dim + '_' + baseAxis.index; |
| const minGap = axisMinGaps[key]; |
| const extentSpan = Math.abs(axisExtent[1] - axisExtent[0]); |
| const scale = baseAxis.scale.getExtent(); |
| const scaleSpan = Math.abs(scale[1] - scale[0]); |
| bandWidth = minGap |
| ? extentSpan / scaleSpan * minGap |
| : extentSpan; // When there is only one data value |
| } |
| else { |
| const data = seriesModel.getData(); |
| bandWidth = Math.abs(axisExtent[1] - axisExtent[0]) / data.count(); |
| } |
| |
| const barWidth = parsePercent( |
| seriesModel.get('barWidth'), bandWidth |
| ); |
| const barMaxWidth = parsePercent( |
| seriesModel.get('barMaxWidth'), bandWidth |
| ); |
| const barMinWidth = parsePercent( |
| // barMinWidth by default is 1 in cartesian. Because in value axis, |
| // the auto-calculated bar width might be less than 1. |
| seriesModel.get('barMinWidth') || 1, bandWidth |
| ); |
| const barGap = seriesModel.get('barGap'); |
| const barCategoryGap = seriesModel.get('barCategoryGap'); |
| |
| seriesInfoList.push({ |
| bandWidth: bandWidth, |
| barWidth: barWidth, |
| barMaxWidth: barMaxWidth, |
| barMinWidth: barMinWidth, |
| barGap: barGap, |
| barCategoryGap: barCategoryGap, |
| axisKey: getAxisKey(baseAxis), |
| stackId: getSeriesStackId(seriesModel) |
| }); |
| }); |
| |
| return doCalBarWidthAndOffset(seriesInfoList); |
| } |
| |
| function doCalBarWidthAndOffset(seriesInfoList: LayoutSeriesInfo[]) { |
| interface ColumnOnAxisInfo { |
| bandWidth: number |
| remainedWidth: number |
| autoWidthCount: number |
| categoryGap: number | string |
| gap: number | string |
| stacks: Dictionary<StackInfo> |
| } |
| |
| // Columns info on each category axis. Key is cartesian name |
| const columnsMap: Dictionary<ColumnOnAxisInfo> = {}; |
| |
| zrUtil.each(seriesInfoList, function (seriesInfo, idx) { |
| const axisKey = seriesInfo.axisKey; |
| const bandWidth = seriesInfo.bandWidth; |
| const columnsOnAxis: ColumnOnAxisInfo = columnsMap[axisKey] || { |
| bandWidth: bandWidth, |
| remainedWidth: bandWidth, |
| autoWidthCount: 0, |
| categoryGap: null, |
| gap: '20%', |
| stacks: {} |
| }; |
| const stacks = columnsOnAxis.stacks; |
| columnsMap[axisKey] = columnsOnAxis; |
| |
| const stackId = seriesInfo.stackId; |
| |
| if (!stacks[stackId]) { |
| columnsOnAxis.autoWidthCount++; |
| } |
| stacks[stackId] = stacks[stackId] || { |
| width: 0, |
| maxWidth: 0 |
| }; |
| |
| // Caution: In a single coordinate system, these barGrid attributes |
| // will be shared by series. Consider that they have default values, |
| // only the attributes set on the last series will work. |
| // Do not change this fact unless there will be a break change. |
| |
| let barWidth = seriesInfo.barWidth; |
| if (barWidth && !stacks[stackId].width) { |
| // See #6312, do not restrict width. |
| stacks[stackId].width = barWidth; |
| barWidth = Math.min(columnsOnAxis.remainedWidth, barWidth); |
| columnsOnAxis.remainedWidth -= barWidth; |
| } |
| |
| const barMaxWidth = seriesInfo.barMaxWidth; |
| barMaxWidth && (stacks[stackId].maxWidth = barMaxWidth); |
| const barMinWidth = seriesInfo.barMinWidth; |
| barMinWidth && (stacks[stackId].minWidth = barMinWidth); |
| const barGap = seriesInfo.barGap; |
| (barGap != null) && (columnsOnAxis.gap = barGap); |
| const barCategoryGap = seriesInfo.barCategoryGap; |
| (barCategoryGap != null) && (columnsOnAxis.categoryGap = barCategoryGap); |
| }); |
| |
| const result: BarWidthAndOffset = {}; |
| |
| zrUtil.each(columnsMap, function (columnsOnAxis, coordSysName) { |
| |
| result[coordSysName] = {}; |
| |
| const stacks = columnsOnAxis.stacks; |
| const bandWidth = columnsOnAxis.bandWidth; |
| let categoryGapPercent = columnsOnAxis.categoryGap; |
| if (categoryGapPercent == null) { |
| const columnCount = zrUtil.keys(stacks).length; |
| // More columns in one group |
| // the spaces between group is smaller. Or the column will be too thin. |
| categoryGapPercent = Math.max((35 - columnCount * 4), 15) + '%'; |
| } |
| |
| const categoryGap = parsePercent(categoryGapPercent, bandWidth); |
| const barGapPercent = parsePercent(columnsOnAxis.gap, 1); |
| |
| let remainedWidth = columnsOnAxis.remainedWidth; |
| let autoWidthCount = columnsOnAxis.autoWidthCount; |
| let autoWidth = (remainedWidth - categoryGap) |
| / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); |
| autoWidth = Math.max(autoWidth, 0); |
| |
| // Find if any auto calculated bar exceeded maxBarWidth |
| zrUtil.each(stacks, function (column) { |
| const maxWidth = column.maxWidth; |
| const minWidth = column.minWidth; |
| |
| if (!column.width) { |
| let finalWidth = autoWidth; |
| if (maxWidth && maxWidth < finalWidth) { |
| finalWidth = Math.min(maxWidth, remainedWidth); |
| } |
| // `minWidth` has higher priority. `minWidth` decide that wheter the |
| // bar is able to be visible. So `minWidth` should not be restricted |
| // by `maxWidth` or `remainedWidth` (which is from `bandWidth`). In |
| // the extreme cases for `value` axis, bars are allowed to overlap |
| // with each other if `minWidth` specified. |
| if (minWidth && minWidth > finalWidth) { |
| finalWidth = minWidth; |
| } |
| if (finalWidth !== autoWidth) { |
| column.width = finalWidth; |
| remainedWidth -= finalWidth + barGapPercent * finalWidth; |
| autoWidthCount--; |
| } |
| } |
| else { |
| // `barMinWidth/barMaxWidth` has higher priority than `barWidth`, as |
| // CSS does. Becuase barWidth can be a percent value, where |
| // `barMaxWidth` can be used to restrict the final width. |
| let finalWidth = column.width; |
| if (maxWidth) { |
| finalWidth = Math.min(finalWidth, maxWidth); |
| } |
| // `minWidth` has higher priority, as described above |
| if (minWidth) { |
| finalWidth = Math.max(finalWidth, minWidth); |
| } |
| column.width = finalWidth; |
| remainedWidth -= finalWidth + barGapPercent * finalWidth; |
| autoWidthCount--; |
| } |
| }); |
| |
| // Recalculate width again |
| autoWidth = (remainedWidth - categoryGap) |
| / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); |
| |
| autoWidth = Math.max(autoWidth, 0); |
| |
| |
| let widthSum = 0; |
| let lastColumn: StackInfo; |
| zrUtil.each(stacks, function (column, idx) { |
| if (!column.width) { |
| column.width = autoWidth; |
| } |
| lastColumn = column; |
| widthSum += column.width * (1 + barGapPercent); |
| }); |
| if (lastColumn) { |
| widthSum -= lastColumn.width * barGapPercent; |
| } |
| |
| let offset = -widthSum / 2; |
| zrUtil.each(stacks, function (column, stackId) { |
| result[coordSysName][stackId] = result[coordSysName][stackId] || { |
| bandWidth: bandWidth, |
| offset: offset, |
| width: column.width |
| } as BarWidthAndOffset[string][string]; |
| |
| offset += column.width * (1 + barGapPercent); |
| }); |
| }); |
| |
| return result; |
| } |
| |
| /** |
| * @param barWidthAndOffset The result of makeColumnLayout |
| * @param seriesModel If not provided, return all. |
| * @return {stackId: {offset, width}} or {offset, width} if seriesModel provided. |
| */ |
| function retrieveColumnLayout(barWidthAndOffset: BarWidthAndOffset, axis: Axis2D): typeof barWidthAndOffset[string]; |
| // eslint-disable-next-line max-len |
| function retrieveColumnLayout(barWidthAndOffset: BarWidthAndOffset, axis: Axis2D, seriesModel: BarSeriesModel): typeof barWidthAndOffset[string][string]; |
| function retrieveColumnLayout( |
| barWidthAndOffset: BarWidthAndOffset, |
| axis: Axis2D, |
| seriesModel?: BarSeriesModel |
| ) { |
| if (barWidthAndOffset && axis) { |
| const result = barWidthAndOffset[getAxisKey(axis)]; |
| if (result != null && seriesModel != null) { |
| return result[getSeriesStackId(seriesModel)]; |
| } |
| return result; |
| } |
| } |
| export {retrieveColumnLayout}; |
| |
| export function layout(seriesType: string, ecModel: GlobalModel) { |
| |
| const seriesModels = prepareLayoutBarSeries(seriesType, ecModel); |
| const barWidthAndOffset = makeColumnLayout(seriesModels); |
| |
| const lastStackCoords: Dictionary<{p: number, n: number}[]> = {}; |
| |
| zrUtil.each(seriesModels, function (seriesModel) { |
| |
| const data = seriesModel.getData(); |
| const cartesian = seriesModel.coordinateSystem as Cartesian2D; |
| const baseAxis = cartesian.getBaseAxis(); |
| |
| const stackId = getSeriesStackId(seriesModel); |
| const columnLayoutInfo = barWidthAndOffset[getAxisKey(baseAxis)][stackId]; |
| const columnOffset = columnLayoutInfo.offset; |
| const columnWidth = columnLayoutInfo.width; |
| const valueAxis = cartesian.getOtherAxis(baseAxis); |
| |
| const barMinHeight = seriesModel.get('barMinHeight') || 0; |
| |
| lastStackCoords[stackId] = lastStackCoords[stackId] || []; |
| |
| data.setLayout({ |
| bandWidth: columnLayoutInfo.bandWidth, |
| offset: columnOffset, |
| size: columnWidth |
| }); |
| |
| const valueDim = data.mapDimension(valueAxis.dim); |
| const baseDim = data.mapDimension(baseAxis.dim); |
| const stacked = isDimensionStacked(data, valueDim /*, baseDim*/); |
| const isValueAxisH = valueAxis.isHorizontal(); |
| |
| const valueAxisStart = getValueAxisStart(baseAxis, valueAxis, stacked); |
| |
| for (let idx = 0, len = data.count(); idx < len; idx++) { |
| const value = data.get(valueDim, idx); |
| const baseValue = data.get(baseDim, idx) as number; |
| |
| const sign = value >= 0 ? 'p' : 'n' as 'p' | 'n'; |
| let baseCoord = valueAxisStart; |
| |
| // Because of the barMinHeight, we can not use the value in |
| // stackResultDimension directly. |
| if (stacked) { |
| // Only ordinal axis can be stacked. |
| if (!lastStackCoords[stackId][baseValue]) { |
| lastStackCoords[stackId][baseValue] = { |
| p: valueAxisStart, // Positive stack |
| n: valueAxisStart // Negative stack |
| }; |
| } |
| // Should also consider #4243 |
| baseCoord = lastStackCoords[stackId][baseValue][sign]; |
| } |
| |
| let x; |
| let y; |
| let width; |
| let height; |
| |
| if (isValueAxisH) { |
| const coord = cartesian.dataToPoint([value, baseValue]); |
| x = baseCoord; |
| y = coord[1] + columnOffset; |
| width = coord[0] - valueAxisStart; |
| height = columnWidth; |
| |
| if (Math.abs(width) < barMinHeight) { |
| width = (width < 0 ? -1 : 1) * barMinHeight; |
| } |
| // Ignore stack from NaN value |
| if (!isNaN(width)) { |
| stacked && (lastStackCoords[stackId][baseValue][sign] += width); |
| } |
| } |
| else { |
| const coord = cartesian.dataToPoint([baseValue, value]); |
| x = coord[0] + columnOffset; |
| y = baseCoord; |
| width = columnWidth; |
| height = coord[1] - valueAxisStart; |
| |
| if (Math.abs(height) < barMinHeight) { |
| // Include zero to has a positive bar |
| height = (height <= 0 ? -1 : 1) * barMinHeight; |
| } |
| // Ignore stack from NaN value |
| if (!isNaN(height)) { |
| stacked && (lastStackCoords[stackId][baseValue][sign] += height); |
| } |
| } |
| |
| data.setItemLayout(idx, { |
| x: x, |
| y: y, |
| width: width, |
| height: height |
| }); |
| } |
| |
| }); |
| } |
| |
| // TODO: Do not support stack in large mode yet. |
| export const largeLayout: StageHandler = { |
| |
| seriesType: 'bar', |
| |
| plan: createRenderPlanner(), |
| |
| reset: function (seriesModel: BarSeriesModel) { |
| if (!isOnCartesian(seriesModel) || !isInLargeMode(seriesModel)) { |
| return; |
| } |
| |
| const data = seriesModel.getData(); |
| const cartesian = seriesModel.coordinateSystem as Cartesian2D; |
| const coordLayout = cartesian.master.getRect(); |
| const baseAxis = cartesian.getBaseAxis(); |
| const valueAxis = cartesian.getOtherAxis(baseAxis); |
| const valueDim = data.mapDimension(valueAxis.dim); |
| const baseDim = data.mapDimension(baseAxis.dim); |
| const valueAxisHorizontal = valueAxis.isHorizontal(); |
| const valueDimIdx = valueAxisHorizontal ? 0 : 1; |
| |
| let barWidth = retrieveColumnLayout( |
| makeColumnLayout([seriesModel]), baseAxis, seriesModel |
| ).width; |
| if (!(barWidth > LARGE_BAR_MIN_WIDTH)) { // jshint ignore:line |
| barWidth = LARGE_BAR_MIN_WIDTH; |
| } |
| |
| return { |
| progress: function (params, data) { |
| const count = params.count; |
| const largePoints = new LargeArr(count * 2); |
| const largeBackgroundPoints = new LargeArr(count * 2); |
| const largeDataIndices = new LargeArr(count); |
| let dataIndex; |
| let coord: number[] = []; |
| const valuePair = []; |
| let pointsOffset = 0; |
| let idxOffset = 0; |
| |
| while ((dataIndex = params.next()) != null) { |
| valuePair[valueDimIdx] = data.get(valueDim, dataIndex); |
| valuePair[1 - valueDimIdx] = data.get(baseDim, dataIndex); |
| |
| coord = cartesian.dataToPoint(valuePair, null); |
| // Data index might not be in order, depends on `progressiveChunkMode`. |
| largeBackgroundPoints[pointsOffset] = |
| valueAxisHorizontal ? coordLayout.x + coordLayout.width : coord[0]; |
| largePoints[pointsOffset++] = coord[0]; |
| largeBackgroundPoints[pointsOffset] = |
| valueAxisHorizontal ? coord[1] : coordLayout.y + coordLayout.height; |
| largePoints[pointsOffset++] = coord[1]; |
| largeDataIndices[idxOffset++] = dataIndex; |
| } |
| |
| data.setLayout({ |
| largePoints: largePoints, |
| largeDataIndices: largeDataIndices, |
| largeBackgroundPoints: largeBackgroundPoints, |
| barWidth: barWidth, |
| valueAxisStart: getValueAxisStart(baseAxis, valueAxis, false), |
| backgroundStart: valueAxisHorizontal ? coordLayout.x : coordLayout.y, |
| valueAxisHorizontal: valueAxisHorizontal |
| }); |
| } |
| }; |
| } |
| }; |
| |
| function isOnCartesian(seriesModel: BarSeriesModel) { |
| return seriesModel.coordinateSystem && seriesModel.coordinateSystem.type === 'cartesian2d'; |
| } |
| |
| function isInLargeMode(seriesModel: BarSeriesModel) { |
| return seriesModel.pipelineContext && seriesModel.pipelineContext.large; |
| } |
| |
| // See cases in `test/bar-start.html` and `#7412`, `#8747`. |
| function getValueAxisStart(baseAxis: Axis2D, valueAxis: Axis2D, stacked?: boolean) { |
| return valueAxis.toGlobalCoord(valueAxis.dataToCoord(valueAxis.type === 'log' ? 1 : 0)); |
| } |