diff --git a/.fatherrc.js b/.fatherrc.js index 2dc6c6051..fc9a463d3 100644 --- a/.fatherrc.js +++ b/.fatherrc.js @@ -1,5 +1,5 @@ export default { - pkgs: ['graphic', 'f2', 'react', 'vue', 'my', 'wx'], + pkgs: ['adjust','scale', 'f2'], cjs: { type: 'babel', }, diff --git a/packages/adjust/LICENSE b/packages/adjust/LICENSE new file mode 100644 index 000000000..3009e073a --- /dev/null +++ b/packages/adjust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 AntV team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/adjust/README.md b/packages/adjust/README.md new file mode 100644 index 000000000..5cf2794f4 --- /dev/null +++ b/packages/adjust/README.md @@ -0,0 +1,25 @@ +# @antv/f2-adjust + +## Installing + +```bash +npm install @antv/f2-adjust +``` + +## Usage + +```js +import { getAdjust } from '@antv/f2-adjust'; + +// contains Dodge, Jitter, Stack, Symmetric +const Dodge = getAdjust('dodge'); + +const d = new Dodge(); + +// adjust the data +const r = d.process(); +``` + +## License + +MIT diff --git a/packages/adjust/package.json b/packages/adjust/package.json new file mode 100644 index 000000000..dd40e0acd --- /dev/null +++ b/packages/adjust/package.json @@ -0,0 +1,33 @@ +{ + "name": "@antv/f2-adjust", + "version": "0.2.5", + "description": "The adjust module for F2.", + "main": "lib/index.js", + "module": "es/index.js", + "types": "es/index.d.ts", + "files": [ + "src", + "lib", + "esm", + "README.md", + "LICENSE" + ], + "dependencies": { + "@antv/util": "~3.3.0", + "@babel/runtime": "^7.12.5" + }, + "repository": { + "type": "git", + "url": "git@github.com:antvis/adjust.git" + }, + "bugs": { + "url": "/~https://github.com/antvis/adjust/issues" + }, + "keywords": [ + "antv", + "adjust", + "f2" + ], + "author": "/~https://github.com/orgs/antvis/people", + "license": "MIT" +} diff --git a/packages/adjust/src/adjusts/adjust.ts b/packages/adjust/src/adjusts/adjust.ts new file mode 100644 index 000000000..aee6bd6b2 --- /dev/null +++ b/packages/adjust/src/adjusts/adjust.ts @@ -0,0 +1,188 @@ +import * as _ from '@antv/util'; +import { DEFAULT_Y } from '../constant'; +import { AdjustCfg, Data, Range } from '../interface'; + +export type AdjustConstructor = new (cfg: any) => Adjust; + +export interface DimValuesMapType { + [dim: string]: number[]; +} + +export default abstract class Adjust { + /** 参与调整的维度 */ + public adjustNames: string[]; + /** x 维度对应的字段 */ + public xField: string; + /** y 维度对应的字段 */ + public yField: string; + + // Dodge 属性 + /** 调整占单位宽度的比例,例如:占 2 个分类间距的 1 / 2 */ + public dodgeRatio: number; + /** 调整过程中 2 个数据的间距,以 dodgeRatio 为分母 */ + public marginRatio: number; + /** 指定进行 dodge 的字段 */ + public dodgeBy: string; + /** 自定义 offset */ + public customOffset: ((data: any, range: any) => number) | number; + + // Stack 属性 + public height: number; + public size: number; + public reverseOrder: boolean; + + /** 像素级组间距 */ + public intervalPadding: number; + /** 像素级组内间距 */ + public dodgePadding: number; + /** x维度长度,计算归一化padding使用 */ + public xDimensionLegenth: number; + /** 分组数 */ + public groupNum: number; + + // 图形宽度相关配置 + /** 用户配置宽度 */ + public defaultSize: number; + /** 最大宽度约束 */ + public maxColumnWidth: number; + /** 最小宽度约束 */ + public minColumnWidth: number; + /** 宽度比例 */ + public columnWidthRatio: number; + + /** 用户自定义的dimValuesMap */ + public dimValuesMap: DimValuesMapType; + + constructor(cfg: AdjustCfg & { dimValuesMap?: DimValuesMapType }) { + const { xField, yField, adjustNames = ['x', 'y'], dimValuesMap } = cfg; + + this.adjustNames = adjustNames; + this.xField = xField; + this.yField = yField; + this.dimValuesMap = dimValuesMap; + } + + // 需要各自实现的方法 + public abstract process(dataArray: Data[][]): Data[][]; + + /** + * 查看维度是否是 adjust 字段 + * @param dim + */ + public isAdjust(dim: string): boolean { + return this.adjustNames.indexOf(dim) >= 0; + } + + protected getAdjustRange(dim: string, dimValue: number, values: number[]): Range { + const { yField } = this; + + const index = values.indexOf(dimValue); + const length = values.length; + + let pre; + let next; + + // 没有 y 字段,但是需要根据 y 调整 + if (!yField && this.isAdjust('y')) { + pre = 0; + next = 1; + } else if (length > 1) { + // 如果以其开头,则取之,否则取他前面一个 + pre = values[index === 0 ? 0 : index - 1]; + // 如果以其结尾,则取之,否则取他后面一个 + next = values[index === length - 1 ? length - 1 : index + 1]; + + if (index !== 0) { + pre += (dimValue - pre) / 2; + } else { + pre -= (next - dimValue) / 2; + } + + if (index !== length - 1) { + next -= (next - dimValue) / 2; + } else { + next += (dimValue - values[length - 2]) / 2; + } + } else { + pre = dimValue === 0 ? 0 : dimValue - 0.5; + next = dimValue === 0 ? 1 : dimValue + 0.5; + } + + return { + pre, + next, + }; + } + + protected adjustData(groupedDataArray: Data[][], mergedData: Data[]) { + // 所有调整维度的值数组 + const dimValuesMap = this.getDimValues(mergedData); + + // 按照每一个分组来进行调整 + _.each(groupedDataArray, (dataArray, index) => { + // 遍历所有数据集合 + // 每个分组中,分别按照不同的 dim 进行调整 + _.each(dimValuesMap, (values: number[], dim: string) => { + // 根据不同的度量分别调整位置 + this.adjustDim(dim, values, dataArray, index); + }); + }); + } + + /** + * 对数据进行分组adjustData + * @param data 数据 + * @param dim 分组的字段 + * @return 分组结果 + */ + protected groupData(data: Data[], dim: string): { [dim: string]: Data[] } { + // 补齐数据空数据为默认值 + _.each(data, (record: Data) => { + if (record[dim] === undefined) { + record[dim] = DEFAULT_Y; + } + }); + + // 按照 dim 维度分组 + return _.groupBy(data, dim); + } + + /** @override */ + protected adjustDim(dim: string, values: number[], data: Data[], index?: number): void {} + + /** + * 获取可调整度量对应的值 + * @param mergedData 数据 + * @return 值的映射 + */ + private getDimValues(mergedData: Data[]): DimValuesMapType { + const { xField, yField } = this; + + const dimValuesMap: DimValuesMapType = _.assign({}, this.dimValuesMap); + + // 所有的维度 + const dims = []; + if (xField && this.isAdjust('x')) { + dims.push(xField); + } + if (yField && this.isAdjust('y')) { + dims.push(yField); + } + + dims.forEach((dim: string): void => { + if (dimValuesMap && dimValuesMap[dim]) { + return; + } + // 在每个维度上,所有的值 + dimValuesMap[dim] = _.valuesOfKey(mergedData, dim).sort((v1, v2) => v1 - v2) as number[]; + }); + + // 只有一维的情况下,同时调整 y,赋予默认值 + if (!yField && this.isAdjust('y')) { + const dim = 'y'; + dimValuesMap[dim] = [DEFAULT_Y, 1]; // 默认分布在 y 轴的 0 与 1 之间 + } + + return dimValuesMap; + } +} diff --git a/packages/adjust/src/adjusts/dodge.ts b/packages/adjust/src/adjusts/dodge.ts new file mode 100644 index 000000000..1e5d36ccb --- /dev/null +++ b/packages/adjust/src/adjusts/dodge.ts @@ -0,0 +1,248 @@ +import * as _ from '@antv/util'; +import { DODGE_RATIO, MARGIN_RATIO } from '../constant'; +import { Data, DodgeCfg, Range } from '../interface'; +import Adjust from './adjust'; + +export default class Dodge extends Adjust { + private cacheMap: { [key: string]: any } = {}; + private adjustDataArray: Data[][] = []; + private mergeData: Data[] = []; + + constructor(cfg: DodgeCfg) { + super(cfg); + const { + marginRatio = MARGIN_RATIO, + dodgeRatio = DODGE_RATIO, + dodgeBy, + intervalPadding, + dodgePadding, + xDimensionLength, + groupNum, + defaultSize, + maxColumnWidth, + minColumnWidth, + columnWidthRatio, + customOffset + } = cfg; + this.marginRatio = marginRatio; + this.dodgeRatio = dodgeRatio; + this.dodgeBy = dodgeBy; + this.intervalPadding = intervalPadding; + this.dodgePadding = dodgePadding; + this.xDimensionLegenth = xDimensionLength; + this.groupNum = groupNum; + this.defaultSize = defaultSize; + this.maxColumnWidth = maxColumnWidth; + this.minColumnWidth = minColumnWidth; + this.columnWidthRatio = columnWidthRatio; + this.customOffset = customOffset; + } + + public process(groupDataArray: Data[][]): Data[][] { + const groupedDataArray = _.clone(groupDataArray); + // 将数据数组展开一层 + const mergeData = _.flatten(groupedDataArray); + + const { dodgeBy } = this; + + // 如果指定了分组 dim 的字段 + const adjustDataArray = dodgeBy ? _.group(mergeData, dodgeBy) : groupedDataArray; + + this.cacheMap = {}; + this.adjustDataArray = adjustDataArray; + this.mergeData = mergeData; + + this.adjustData(adjustDataArray, mergeData); + + this.adjustDataArray = []; + this.mergeData = []; + + return groupedDataArray; + } + + protected adjustDim(dim: string, values: number[], data: Data[], frameIndex: number): any[] { + const { customOffset } = this; + const map = this.getDistribution(dim); + const groupData = this.groupData(data, dim); // 根据值分组 + + _.each(groupData, (group, key) => { + let range: Range; + + // xField 中只有一个值,不需要做 dodge + if (values.length === 1) { + range = { + pre: values[0] - 1, + next: values[0] + 1, + }; + } else { + // 如果有多个,则需要获取调整的范围 + range = this.getAdjustRange(dim, parseFloat(key), values); + } + _.each(group, (d) => { + const value = d[dim]; + const valueArr = map[value]; + const valIndex = valueArr.indexOf(frameIndex); + if (!_.isNil(customOffset)) { + const { pre, next } = range; + d[dim] = _.isFunction(customOffset) ? customOffset(d, range) : (pre + next) / 2 + customOffset; + } else { + d[dim] = this.getDodgeOffset(range, valIndex, valueArr.length); + } + }); + }); + return []; + } + + private getDodgeOffset(range: Range, idx: number, len: number): number { + const { + dodgeRatio, + marginRatio, + intervalPadding, + dodgePadding, + } = this; + const { pre, next } = range; + + const tickLength = next - pre; + let position; + // 分多种输入情况 + if (!_.isNil(intervalPadding) && _.isNil(dodgePadding) && intervalPadding >= 0) { + // 仅配置intervalPadding + const offset = this.getIntervalOnlyOffset(len, idx); + position = pre + offset; + } else if (!_.isNil(dodgePadding) && _.isNil(intervalPadding) && dodgePadding >= 0) { + // 仅配置dodgePadding + const offset = this.getDodgeOnlyOffset(len, idx); + position = pre + offset; + } else if ( + !_.isNil(intervalPadding) && + !_.isNil(dodgePadding) && + intervalPadding >= 0 && + dodgePadding >= 0 + ) { + // 同时配置intervalPadding和dodgePadding + const offset = this.getIntervalAndDodgeOffset(len, idx); + position = pre + offset; + } else { + // 默认情况 + const width = (tickLength * dodgeRatio) / len; + const margin = marginRatio * width; + const offset = + (1 / 2) * (tickLength - len * width - (len - 1) * margin) + + ((idx + 1) * width + idx * margin) - + (1 / 2) * width - + (1 / 2) * tickLength; + position = (pre + next) / 2 + offset; + } + return position; + } + + private getIntervalOnlyOffset(len: number, idx: number): number { + const { + defaultSize, + intervalPadding, + xDimensionLegenth, + groupNum, + dodgeRatio, + maxColumnWidth, + minColumnWidth, + columnWidthRatio, + } = this; + const normalizedIntervalPadding = intervalPadding / xDimensionLegenth; + let normalizedDodgePadding = (1 - (groupNum - 1) * normalizedIntervalPadding) / groupNum * dodgeRatio / (len - 1); + let geomWidth = ((1 - normalizedIntervalPadding * (groupNum - 1)) / groupNum - normalizedDodgePadding * (len - 1)) / len; + // 根据columnWidthRatio/defaultSize/maxColumnWidth/minColumnWidth调整宽度 + geomWidth = (!_.isNil(columnWidthRatio)) ? 1 / groupNum / len * columnWidthRatio : geomWidth; + if (!_.isNil(maxColumnWidth)) { + const normalizedMaxWidht = maxColumnWidth / xDimensionLegenth; + geomWidth = Math.min(geomWidth, normalizedMaxWidht); + } + if (!_.isNil(minColumnWidth)) { + const normalizedMinWidht = minColumnWidth / xDimensionLegenth; + geomWidth = Math.max(geomWidth, normalizedMinWidht); + } + geomWidth = defaultSize ? (defaultSize / xDimensionLegenth) : geomWidth; + // 调整组内间隔 + normalizedDodgePadding = ((1 - (groupNum - 1) * normalizedIntervalPadding) / groupNum - len * geomWidth) / (len - 1); + const offset = + ((1 / 2 + idx) * geomWidth + idx * normalizedDodgePadding + + (1 / 2) * normalizedIntervalPadding) * groupNum - + normalizedIntervalPadding / 2; + return offset; + } + + private getDodgeOnlyOffset(len: number, idx: number): number { + const { + defaultSize, + dodgePadding, + xDimensionLegenth, + groupNum, + marginRatio, + maxColumnWidth, + minColumnWidth, + columnWidthRatio, + } = this; + const normalizedDodgePadding = dodgePadding / xDimensionLegenth; + let normalizedIntervalPadding = 1 * marginRatio / (groupNum - 1); + let geomWidth = ((1 - normalizedIntervalPadding * (groupNum - 1)) / groupNum - normalizedDodgePadding * (len - 1)) / len; + // 根据columnWidthRatio/defaultSize/maxColumnWidth/minColumnWidth调整宽度 + geomWidth = columnWidthRatio ? 1 / groupNum / len * columnWidthRatio : geomWidth; + if (!_.isNil(maxColumnWidth)) { + const normalizedMaxWidht = maxColumnWidth / xDimensionLegenth; + geomWidth = Math.min(geomWidth, normalizedMaxWidht); + } + if (!_.isNil(minColumnWidth)) { + const normalizedMinWidht = minColumnWidth / xDimensionLegenth; + geomWidth = Math.max(geomWidth, normalizedMinWidht); + } + geomWidth = defaultSize ? (defaultSize / xDimensionLegenth) : geomWidth; + // 调整组间距 + normalizedIntervalPadding = (1 - (geomWidth * len + normalizedDodgePadding * (len - 1)) * groupNum) / (groupNum - 1); + const offset = + ((1 / 2 + idx) * geomWidth + idx * normalizedDodgePadding + + (1 / 2) * normalizedIntervalPadding) * groupNum - + normalizedIntervalPadding / 2; + return offset; + } + + private getIntervalAndDodgeOffset(len: number, idx: number): number { + const { + intervalPadding, + dodgePadding, + xDimensionLegenth, + groupNum, + } = this; + const normalizedIntervalPadding = intervalPadding / xDimensionLegenth; + const normalizedDodgePadding = dodgePadding / xDimensionLegenth; + const geomWidth = ((1 - normalizedIntervalPadding * (groupNum - 1)) / groupNum - normalizedDodgePadding * (len - 1)) / len; + const offset = + ((1 / 2 + idx) * geomWidth + idx * normalizedDodgePadding + + (1 / 2) * normalizedIntervalPadding) * groupNum - + normalizedIntervalPadding / 2; + return offset; + } + + private getDistribution(dim: string) { + const groupedDataArray = this.adjustDataArray; + const cacheMap = this.cacheMap; + let map = cacheMap[dim]; + + if (!map) { + map = {}; + _.each(groupedDataArray, (data, index) => { + const values = _.valuesOfKey(data, dim) as number[]; + if (!values.length) { + values.push(0); + } + _.each(values, (val: number) => { + if (!map[val]) { + map[val] = []; + } + map[val].push(index); + }); + }); + cacheMap[dim] = map; + } + + return map; + } +} diff --git a/packages/adjust/src/adjusts/jitter.ts b/packages/adjust/src/adjusts/jitter.ts new file mode 100644 index 000000000..2c0a11d14 --- /dev/null +++ b/packages/adjust/src/adjusts/jitter.ts @@ -0,0 +1,55 @@ +import * as _ from '@antv/util'; +import { GAP } from '../constant'; +import { Data, Range } from '../interface'; +import Adjust from './adjust'; + +function randomNumber(min: number, max: number): number { + return (max - min) * Math.random() + min; +} + +export default class Jitter extends Adjust { + public process(groupDataArray: Data[][]): Data[][] { + const groupedDataArray = _.clone(groupDataArray); + + // 之前分组之后的数据,然后有合并回去(和分组前可以理解成是一样的) + const mergeData = _.flatten(groupedDataArray) as Data[]; + + // 返回值 + this.adjustData(groupedDataArray, mergeData); + + return groupedDataArray; + } + + /** + * 当前数据分组(index)中,按照维度 dim 进行 jitter 调整 + * @param dim + * @param values + * @param dataArray + */ + protected adjustDim(dim: string, values: number[], dataArray: Data[]) { + // 在每一个分组中,将数据再按照 dim 分组,用于散列 + const groupDataArray = this.groupData(dataArray, dim); + return _.each(groupDataArray, (data: Data[], dimValue: string) => { + return this.adjustGroup(data, dim, parseFloat(dimValue), values); + }); + } + + // 随机出来的字段值 + private getAdjustOffset(range: Range): number { + const { pre, next } = range; + // 随机的范围 + const margin = (next - pre) * GAP; + return randomNumber(pre + margin, next - margin); + } + + // adjust group data + private adjustGroup(group: Data[], dim: string, dimValue: number, values: number[]): Data[] { + // 调整范围 + const range = this.getAdjustRange(dim, dimValue, values); + + _.each(group, (data: Data) => { + data[dim] = this.getAdjustOffset(range); // 获取调整的位置 + }); + return group; + } +} diff --git a/packages/adjust/src/adjusts/stack.ts b/packages/adjust/src/adjusts/stack.ts new file mode 100644 index 000000000..7dcae7a6d --- /dev/null +++ b/packages/adjust/src/adjusts/stack.ts @@ -0,0 +1,116 @@ +import * as _ from '@antv/util'; +import { Data, StackCfg } from '../interface'; +import Adjust from './adjust'; + +const Cache = _.Cache; + +export default class Stack extends Adjust { + constructor(cfg: StackCfg) { + super(cfg); + + const { adjustNames = ['y'], height = NaN, size = 10, reverseOrder = false } = cfg; + this.adjustNames = adjustNames; + this.height = height; + this.size = size; + this.reverseOrder = reverseOrder; + } + + /** + * 方法入参是经过数据分组、数据数字化之后的二维数组 + * @param groupDataArray 分组之后的数据 + */ + public process(groupDataArray: Data[][]): Data[][] { + const { yField, reverseOrder } = this; + + // 如果有指定 y 字段,那么按照 y 字段来 stack + // 否则,按照高度均分 + const d = yField ? this.processStack(groupDataArray) : this.processOneDimStack(groupDataArray); + + return reverseOrder ? this.reverse(d) : d; + } + + private reverse(groupedDataArray: Data[][]): Data[][] { + return groupedDataArray.slice(0).reverse(); + } + + private processStack(groupDataArray: Data[][]): Data[][] { + const { xField, yField, reverseOrder } = this; + + // 层叠顺序翻转 + const groupedDataArray = reverseOrder ? this.reverse(groupDataArray) : groupDataArray; + + // 用来缓存,正数和负数的堆叠问题 + const positive = new Cache(); + const negative = new Cache(); + + return groupedDataArray.map((dataArray) => { + return dataArray.map((data) => { + const x: number = _.get(data, xField, 0); + let y: number = _.get(data, [yField]); + + const xKey = x.toString(); + + // todo 是否应该取 _origin?因为 y 可能取到的值不正确,比如先 symmetric,再 stack! + y = _.isArray(y) ? y[1] : y; + + if (!_.isNil(y)) { + const cache = y >= 0 ? positive : negative; + + if (!cache.has(xKey)) { + cache.set(xKey, 0); + } + const xValue = cache.get(xKey) as number; + const newXValue = y + xValue; + + // 存起来 + cache.set(xKey, newXValue); + + return { + ...data, + // 叠加成数组,覆盖之前的数据 + [yField]: [xValue, newXValue], + }; + } + + // 没有修改,则直接返回 + return data; + }); + }); + } + + private processOneDimStack(groupDataArray: Data[][]): Data[][] { + const { xField, height, reverseOrder } = this; + const yField = 'y'; + + // 如果层叠的顺序翻转 + const groupedDataArray = reverseOrder ? this.reverse(groupDataArray) : groupDataArray; + + // 缓存累加数据 + const cache = new Cache(); + + return groupedDataArray.map((dataArray): Data[] => { + return dataArray.map( + (data): Data => { + const { size } = this; + const xValue: string = data[xField]; + + // todo 没有看到这个 stack 计算原理 + const stackHeight = (size * 2) / height; + + if (!cache.has(xValue)) { + cache.set(xValue, stackHeight / 2); // 初始值大小 + } + + const stackValue = cache.get(xValue) as number; + // 增加一层 stackHeight + cache.set(xValue, stackValue + stackHeight); + + return { + ...data, + [yField]: stackValue, + }; + } + ); + }); + } +} diff --git a/packages/adjust/src/adjusts/symmetric.ts b/packages/adjust/src/adjusts/symmetric.ts new file mode 100644 index 000000000..76bcb456e --- /dev/null +++ b/packages/adjust/src/adjusts/symmetric.ts @@ -0,0 +1,62 @@ +import * as _ from '@antv/util'; +import { Data } from '../interface'; +import Adjust from './adjust'; + +export default class Symmetric extends Adjust { + public process(groupDataArray: Data[][]): Data[][] { + const mergeData = _.flatten(groupDataArray); + + const { xField, yField } = this; + + // 每个 x 值对应的 最大值 + const cache = this.getXValuesMaxMap(mergeData); + + // 所有数据的最大的值 + const max = Math.max(...Object.keys(cache).map((key) => cache[key])); + + return _.map(groupDataArray, (dataArray) => { + return _.map(dataArray, (data) => { + const yValue = data[yField]; + const xValue = data[xField]; + + // 数组处理逻辑 + if (_.isArray(yValue)) { + const off = (max - cache[xValue]) / 2; + + return { + ...data, + [yField]: _.map(yValue, (y: number) => off + y), + }; + } + + // 非数组处理逻辑 + const offset = (max - yValue) / 2; + return { + ...data, + [yField]: [offset, yValue + offset], + }; + }); + }); + } + + // 获取每个 x 对应的最大的值 + private getXValuesMaxMap(mergeData: Data[]): { [key: string]: number } { + const { xField, yField } = this; + + // 根据 xField 的值进行分组 + const groupDataArray = _.groupBy(mergeData, (data) => data[xField] as string); + + // 获取每个 xField 值中的最大值 + return _.mapValues(groupDataArray, (dataArray) => this.getDimMaxValue(dataArray, yField)); + } + + private getDimMaxValue(mergeData: Data[], dim: string): number { + // 所有的 value 值 + const dimValues = _.map(mergeData, (data) => _.get(data, dim, [])); + // 将数组打平(dim value 有可能是数组,比如 stack 之后的) + const flattenValues = _.flatten(dimValues); + + // 求出数组的最大值 + return Math.max(...flattenValues); + } +} diff --git a/packages/adjust/src/constant.ts b/packages/adjust/src/constant.ts new file mode 100644 index 000000000..5ba1eee51 --- /dev/null +++ b/packages/adjust/src/constant.ts @@ -0,0 +1,8 @@ +export const DEFAULT_Y = 0; // 默认的 y 的值 + +// 偏移之后,间距 +export const MARGIN_RATIO = 1 / 2; +export const DODGE_RATIO = 1 / 2; + +// 散点分开之后,距离边界的距离 +export const GAP = 0.05; diff --git a/packages/adjust/src/factory.ts b/packages/adjust/src/factory.ts new file mode 100644 index 000000000..e4beaea15 --- /dev/null +++ b/packages/adjust/src/factory.ts @@ -0,0 +1,33 @@ +import Adjust, { AdjustConstructor } from './adjusts/adjust'; + +interface AdjustMapType { + [type: string]: AdjustConstructor; +} + +const ADJUST_MAP: AdjustMapType = {}; + +/** + * 根据类型获取 Adjust 类 + * @param type + */ +const getAdjust = (type: string): AdjustConstructor => { + return ADJUST_MAP[type.toLowerCase()]; +}; + +/** + * 注册自定义 Adjust + * @param type + * @param ctor + */ +const registerAdjust = (type: string, ctor: AdjustConstructor): void => { + // 注册的时候,需要校验 type 重名,不区分大小写 + if (getAdjust(type)) { + throw new Error(`Adjust type '${type}' existed.`); + } + // 存储到 map 中 + ADJUST_MAP[type.toLowerCase()] = ctor; +}; + +export { getAdjust, registerAdjust, Adjust }; + +export * from './interface'; diff --git a/packages/adjust/src/index.ts b/packages/adjust/src/index.ts new file mode 100644 index 000000000..932990802 --- /dev/null +++ b/packages/adjust/src/index.ts @@ -0,0 +1,19 @@ +import { getAdjust, registerAdjust } from './factory'; + +import Adjust from './adjusts/adjust'; + +import Dodge from './adjusts/dodge'; +import Jitter from './adjusts/jitter'; +import Stack from './adjusts/stack'; +import Symmetric from './adjusts/symmetric'; + +// 注册内置的 adjust +registerAdjust('Dodge', Dodge); +registerAdjust('Jitter', Jitter); +registerAdjust('Stack', Stack); +registerAdjust('Symmetric', Symmetric); + +// 最终暴露给外部的方法 +export { getAdjust, registerAdjust, Adjust }; + +export * from './interface'; diff --git a/packages/adjust/src/interface.ts b/packages/adjust/src/interface.ts new file mode 100644 index 000000000..e053adb24 --- /dev/null +++ b/packages/adjust/src/interface.ts @@ -0,0 +1,50 @@ +export interface AdjustCfg { + readonly adjustNames?: string[]; + readonly xField?: string; + readonly yField?: string; + + readonly dodgeBy?: string; + readonly marginRatio?: number; + readonly dodgeRatio?: number; + + readonly size?: number; + readonly height?: number; + readonly reverseOrder?: boolean; +} + +export interface DodgeCfg { + readonly adjustNames?: string[]; + readonly xField: string; + readonly yField?: string; + readonly marginRatio?: number; + readonly dodgeRatio?: number; + readonly dodgeBy?: string; + readonly intervalPadding?: number; + readonly dodgePadding?: number; + readonly xDimensionLength?: number; + readonly groupNum?: number; + readonly defaultSize?: number; + readonly maxColumnWidth?: number; + readonly minColumnWidth?: number; + readonly columnWidthRatio?: number; + readonly customOffset?: ((data: any, range: any) => number) | number; +} + +export interface StackCfg { + readonly adjustNames?: string[]; + readonly xField: string; + readonly yField?: string; + + readonly height?: number; + readonly size?: number; + readonly reverseOrder?: boolean; +} + +export interface Data { + [key: string]: any; +} + +export interface Range { + pre: number; + next: number; +} diff --git a/packages/f2/package.json b/packages/f2/package.json index 3b465150e..be20f9dab 100644 --- a/packages/f2/package.json +++ b/packages/f2/package.json @@ -31,10 +31,10 @@ "module": "es/index.js", "types": "es/index.d.ts", "dependencies": { - "@antv/adjust": "~0.2.5", + "@antv/f2-adjust": "*", "@antv/event-emitter": "^0.1.2", "@antv/f-engine": "~0.0.2", - "@antv/scale": "~0.3.3", + "@antv/f2-scale": "*", "@antv/util": "~3.3.0", "@babel/runtime": "^7.12.5", "d3-cloud": "~1.2.5" diff --git a/packages/f2/src/attr/base.ts b/packages/f2/src/attr/base.ts index 675234312..df535b0d0 100644 --- a/packages/f2/src/attr/base.ts +++ b/packages/f2/src/attr/base.ts @@ -1,4 +1,4 @@ -import { Scale, ScaleConfig } from '@antv/scale'; +import { Scale, ScaleConfig } from '@antv/f2-scale'; import { mix, isFunction, isNil, isArray, valuesOfKey } from '@antv/util'; class Base { diff --git a/packages/f2/src/attr/category.ts b/packages/f2/src/attr/category.ts index c0927b415..e9a7a6f92 100644 --- a/packages/f2/src/attr/category.ts +++ b/packages/f2/src/attr/category.ts @@ -1,4 +1,4 @@ -import { Category as CategoryScale, ScaleConfig } from '@antv/scale'; +import { Category as CategoryScale, ScaleConfig } from '@antv/f2-scale'; import Base from './base'; class Category extends Base { diff --git a/packages/f2/src/attr/identity.ts b/packages/f2/src/attr/identity.ts index 296572b66..eb35bdd9c 100644 --- a/packages/f2/src/attr/identity.ts +++ b/packages/f2/src/attr/identity.ts @@ -1,4 +1,4 @@ -import { Identity as IdentityScale, ScaleConfig } from '@antv/scale'; +import { Identity as IdentityScale, ScaleConfig } from '@antv/f2-scale'; import Base from './base'; class Identity extends Base { diff --git a/packages/f2/src/attr/linear.ts b/packages/f2/src/attr/linear.ts index 982cfcdd0..99de89961 100644 --- a/packages/f2/src/attr/linear.ts +++ b/packages/f2/src/attr/linear.ts @@ -1,4 +1,4 @@ -import { Linear as LinearScale, ScaleConfig } from '@antv/scale'; +import { Linear as LinearScale, ScaleConfig } from '@antv/f2-scale'; import { isArray } from '@antv/util'; import { interpolate } from '../deps/d3-interpolate/src'; import Base from './base'; diff --git a/packages/f2/src/chart/index.ts b/packages/f2/src/chart/index.ts index fbbecbd32..97cb6c26f 100644 --- a/packages/f2/src/chart/index.ts +++ b/packages/f2/src/chart/index.ts @@ -1,5 +1,5 @@ import { JSX } from '../index'; -import { ScaleConfig } from '@antv/scale'; +import { ScaleConfig } from '@antv/f2-scale'; import { each, findIndex, isArray } from '@antv/util'; import equal from '../base/equal'; import { Layout, Component } from '../index'; diff --git a/packages/f2/src/components/area/areaView.tsx b/packages/f2/src/components/area/areaView.tsx index 6da4a678c..858e8cbcb 100644 --- a/packages/f2/src/components/area/areaView.tsx +++ b/packages/f2/src/components/area/areaView.tsx @@ -15,8 +15,8 @@ export default (props) => { type: 'sector', property: ['endAngle'], attrs: { - x: center.x - left, - y: center.y - top, + x: center.x, + y: center.y, startAngle, r: radius, }, @@ -35,6 +35,8 @@ export default (props) => { type: 'rect', property: ['width'], attrs: { + x: left, + y: top, height: height, }, start: { diff --git a/packages/f2/src/components/geometry/index.tsx b/packages/f2/src/components/geometry/index.tsx index f4dfcea6f..86e9b86d3 100644 --- a/packages/f2/src/components/geometry/index.tsx +++ b/packages/f2/src/components/geometry/index.tsx @@ -1,11 +1,11 @@ import { isFunction, each, upperFirst, mix, groupToMap, isObject, flatten } from '@antv/util'; import Selection, { SelectionState } from './selection'; -import { Adjust, getAdjust } from '@antv/adjust'; +import { Adjust, getAdjust } from '@antv/f2-adjust'; import { toTimeStamp } from '../../util/index'; import { GeomType, GeometryProps, GeometryAdjust } from './interface'; import AttrController from '../../controller/attr'; import equal from '../../base/equal'; -import { Scale } from '@antv/scale'; +import { Scale } from '@antv/f2-scale'; import { Types } from '@antv/f-engine'; // 保留原始数据的字段 diff --git a/packages/f2/src/components/line/lineView.tsx b/packages/f2/src/components/line/lineView.tsx index b9e386cd7..346978096 100644 --- a/packages/f2/src/components/line/lineView.tsx +++ b/packages/f2/src/components/line/lineView.tsx @@ -82,8 +82,8 @@ export default (props: LineViewProps) => { type: 'sector', property: ['endAngle'], attrs: { - x: center.x - left, - y: center.y - top, + x: center.x, + y: center.y, startAngle, r: radius, }, @@ -102,8 +102,8 @@ export default (props: LineViewProps) => { type: 'rect', property: ['width'], attrs: { - // x: left, - // y: top, + x: left, + y: top, height: height, }, start: { diff --git a/packages/f2/src/components/zoom/index.tsx b/packages/f2/src/components/zoom/index.tsx index 94266b912..aaa09a819 100644 --- a/packages/f2/src/components/zoom/index.tsx +++ b/packages/f2/src/components/zoom/index.tsx @@ -1,7 +1,7 @@ import { Component } from '@antv/f-engine'; import { ChartChildProps } from '../../chart'; import { updateRange, updateFollow } from './zoomUtil'; -import { Scale, ScaleConfig } from '@antv/scale'; +import { Scale, ScaleConfig } from '@antv/f2-scale'; import { each, isNumberEqual } from '@antv/util'; import equal from '../../base/equal'; diff --git a/packages/f2/src/components/zoom/zoomUtil.ts b/packages/f2/src/components/zoom/zoomUtil.ts index 61976944b..3c3e65050 100644 --- a/packages/f2/src/components/zoom/zoomUtil.ts +++ b/packages/f2/src/components/zoom/zoomUtil.ts @@ -1,5 +1,5 @@ import { ScaleValues, ZoomRange } from './index'; -import { Scale, getTickMethod } from '@antv/scale'; +import { Scale, getTickMethod } from '@antv/f2-scale'; import { getRange } from '@antv/util'; import { toTimeStamp } from '../../util'; diff --git a/packages/f2/src/controller/attr.ts b/packages/f2/src/controller/attr.ts index dea4247a1..75d4c1122 100644 --- a/packages/f2/src/controller/attr.ts +++ b/packages/f2/src/controller/attr.ts @@ -2,7 +2,7 @@ import { each, isString, isNil, isFunction, isNumber, isArray, upperFirst } from import * as Attrs from '../attr'; import equal from '../base/equal'; import ScaleController from './scale'; -import { Scale, ScaleConfig } from '@antv/scale'; +import { Scale, ScaleConfig } from '@antv/f2-scale'; type AttrOption = { field?: string | Record; diff --git a/packages/f2/src/controller/scale.ts b/packages/f2/src/controller/scale.ts index c5bcaceac..3e1ca6d79 100644 --- a/packages/f2/src/controller/scale.ts +++ b/packages/f2/src/controller/scale.ts @@ -1,4 +1,4 @@ -import { getScale, registerTickMethod, Scale, ScaleConfig } from '@antv/scale'; +import { getScale, registerTickMethod, Scale, ScaleConfig } from '@antv/f2-scale'; import { each, getRange, isFunction, isNil, isNumber, mix, valuesOfKey } from '@antv/util'; import CatTick from './scale/cat-tick'; import LinearTick from './scale/linear-tick'; diff --git a/packages/f2/src/controller/scale/index.ts b/packages/f2/src/controller/scale/index.ts index 9f362a1ad..f3ca8b0b2 100644 --- a/packages/f2/src/controller/scale/index.ts +++ b/packages/f2/src/controller/scale/index.ts @@ -1,4 +1,4 @@ -import { registerTickMethod } from '@antv/scale'; +import { registerTickMethod } from '@antv/f2-scale'; import CatTick from './cat-tick'; import LinearTick from './linear-tick'; diff --git a/packages/f2/src/controller/scale/time-cat.ts b/packages/f2/src/controller/scale/time-cat.ts index 262785b3f..06b6fb98f 100644 --- a/packages/f2/src/controller/scale/time-cat.ts +++ b/packages/f2/src/controller/scale/time-cat.ts @@ -1,4 +1,4 @@ -import { getScale, registerTickMethod } from '@antv/scale'; +import { getScale, registerTickMethod } from '@antv/f2-scale'; import CatTick from './cat-tick'; registerTickMethod('time-cat', CatTick); diff --git a/packages/f2/src/types.ts b/packages/f2/src/types.ts index 9e2d74914..6ecac8173 100644 --- a/packages/f2/src/types.ts +++ b/packages/f2/src/types.ts @@ -36,7 +36,7 @@ export interface DataRecord { [k: string]: any; } -// export * from '@antv/scale' +// export * from '@antv/f2-scale' export type { AxisTypes } type SupportPx = { diff --git "a/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-1-snap.png" "b/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-1-snap.png" deleted file mode 100644 index edd00453c..000000000 Binary files "a/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-1-snap.png" and /dev/null differ diff --git "a/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-\345\270\246\350\264\237\345\200\274\351\235\242\347\247\257\345\233\276-x\345\237\272\347\272\277\344\270\215\344\270\272-0-1-snap.png" "b/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-\345\270\246\350\264\237\345\200\274\351\235\242\347\247\257\345\233\276-x\345\237\272\347\272\277\344\270\215\344\270\272-0-1-snap.png" deleted file mode 100644 index a32784432..000000000 Binary files "a/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\237\272\347\241\200\351\235\242\347\247\257\345\233\276-\345\270\246\350\264\237\345\200\274\351\235\242\347\247\257\345\233\276-x\345\237\272\347\272\277\344\270\215\344\270\272-0-1-snap.png" and /dev/null differ diff --git "a/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\261\202\345\217\240\351\235\242\347\247\257\345\233\276-\345\261\202\345\217\240\351\235\242\347\247\257\345\233\276-1-snap.png" "b/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\261\202\345\217\240\351\235\242\347\247\257\345\233\276-\345\261\202\345\217\240\351\235\242\347\247\257\345\233\276-1-snap.png" deleted file mode 100644 index 6085115c3..000000000 Binary files "a/packages/f2/test/components/area/__image_snapshots__/area-test-tsx-\351\235\242\347\247\257\345\233\276-\345\261\202\345\217\240\351\235\242\347\247\257\345\233\276-\345\261\202\345\217\240\351\235\242\347\247\257\345\233\276-1-snap.png" and /dev/null differ diff --git a/packages/scale/LICENSE b/packages/scale/LICENSE new file mode 100644 index 000000000..3009e073a --- /dev/null +++ b/packages/scale/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 AntV team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/scale/README.md b/packages/scale/README.md new file mode 100644 index 000000000..323194992 --- /dev/null +++ b/packages/scale/README.md @@ -0,0 +1,133 @@ +# `@antv/f2-scale` + +## Description + +scale 有很多中文名,标度、度量、比例尺等等。它是数据空间到图形空间的转换桥梁,负责将数据从数据空间(定义域)转换为图形属性空间区域(值域),下文都称为度量。 + +例如: + +![](https://www.oxxostudio.tw/img/articles/201411/20141112_1_04.png) + +或者 + +![](https://www.oxxostudio.tw/img/articles/201411/20141112_1_05.png) + +## Usage + +```ts +import { getScale } from '@antv/f2-scale'; + +const Linear = getScale('linear'); + +// 详情可参考单测用例 +const scale = new Linear({ + min: 0, + max: 100, + range: [0, 1], +}); + +scale.scale(30); // 0.3 +scale.invert(0.3); // 30 +scale.getText(30); // '30' +``` + +## API + +Scale 度量模块提供了下面 3 大类的度量 + +- 分类度量: + - cat: 分类度量 + - timeCat: 时间分类度量 +- 连续度量: + - linear: 线性度量 + - time:连续的时间度量 + - log: log 度量 + - pow: pow 度量 + - quantize:分段度量,用户可以指定不均匀的分段 + - quantile: 等分度量,根据数据的分布自动计算分段 +- 常量度量 + - identity: 常量度量这些度量的使用通过 getScale 方法来获取 + +```js +import { getScale } from '@antv/scale'; + +const Linear = getScale('linear'); +const TimeCat = getScale('timeCat'); +``` + +度量的属性大部分一致,可以将属性分为: + +- 通用属性: 所有度量都适用的属性 +- 度量的专有属性:个别度量专有的属性,对其他度量无意义 + +### 通用属性 + +| 名称 | 类型 | 说明 | +| ---------- | ------------------ | -------------------------------------- | +| type | string | 度量 类型 | +| values | any[] | 定义域 | +| min | any | 定义域的最小值,在分类型 度量 中为序号 | +| max | any | 定义域的最大值 | +| range | [number, number] | 值域的最小、最大值 | +| tickCount | number | 期望的 tick 数量,非最终结果 | +| formatter | func(value, index) | 格式化函数,用于 tooltip、tick 等展示 | +| tickMethod | string/func(scale) | 计算 ticks 的方法 | + +#### tickMethod + +这个属性用户设置计算 ticks 的方法,可以传入字符串或者回调函数,支持的字符串类型有: + +- `wilkinson-extended` :计算数字 ticks 的方法,linear 类型度量内置的计算方法 +- `r-pretty`: 计算数字 ticks 的方法, ticks 的 nice 效果很好,但是 tickCount 的精度太差 +- `time`: 时间 ticks 的计算方法,计算出一个 tickInterval,坐标刻度之间的间隔固定 +- `time-pretty`: 时间 ticks 的计算方法,会对年、月进行优化,time 类型度量内置的计算方法 +- `log`: 计算数字的 ticks 方法,按照 log 的函数来计算,生成 [0, 10, 100, 1000] 类似的 ticks +- `pow`: 计算数字的 ticks 方法,按照 pow 的函数来计算,生成 [0, 4, 9, 16] 类似的 ticks +- `quantile`: 计算数字的 ticks 方法,根据统计学上的 几分位 概念计算 ticks,表现的是数据的分布 + +### 通用的 Methods + +> 所有的 Scale 仅开放下面的方法,不提供任何其他方法 + +| 名称 | 类型 | 说明 | +| --------- | --------------------- | ---------------------------------- | +| scale | (value: any): number | 将定义域的输入值转换为值域的输出值 | +| invert | (scaled: number): any | 将值域的输入值转换为定义域的输出值 | +| translate | (value: any): number | 分类型 度量 中,将定义域转化为序号 | +| clone | (): void | 复制 度量 实例 | +| getTicks | (): Tick[] | 获取所有 ticks 集合 | +| getText | (value: any): string | 获取输入值的展示结果 | +| change | (cfg) | 修改度量 | + +### 专有属性 + +这里除了列举各个度量专有的属性,和一些属性适合的取值,例如 tickMethod 方法不同的度量适合的计算方式不一样,任意设置可能达不到想要的效果 + +#### pow + +| 名称 | 类型 | 说明 | | exponent | number | 指数 | + +#### log + +| 名称 | 类型 | 说明 | | base | number | 对数底数 | + +#### quantize + +这是一种分段度量,scale 按照用户设置的 ticks 进行计算 scale,如果未设置 ticks ,则使用 `r-pretty` 计算默认的 ticks + +#### quantile + +这是一种按照数据密度自动分段的度量,按照设置的 values 计算 ticks,进行 scale 时按照 ticks 计算,而非均匀计算,使用 `tickMethod: quantile` 计算 ticks + +## 与 2.x 的兼容性问题 + +### 新增的特性 + +- tickMethod:2.x 计算 ticks 的算法都是固定在各个度量内部,3.x 中提供了用户改变计算 ticks 算法的接口 +- min, max:3.x 中 cat、timeCat 类型的 min, max 可以指定 +- 新增两种 scale: quantize, quantile + +### 不再支持的属性(暂时不支持) + +- tickInterval, minTickInterval:在 linear、log、pow 度量中不再支持 +- transform:3.x 中移除这个函数,大多数度量中使用不到 diff --git a/packages/scale/package.json b/packages/scale/package.json new file mode 100644 index 000000000..51f390b9b --- /dev/null +++ b/packages/scale/package.json @@ -0,0 +1,26 @@ +{ + "name": "@antv/f2-scale", + "version": "0.0.0", + "description": "The scale module for F2", + "author": "/~https://github.com/orgs/antvis/people", + "license": "MIT", + "main": "lib/index.js", + "module": "es/index.js", + "types": "es/index.d.ts", + "files": [ + "src", + "lib", + "esm", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "git@github.com:antvis/scale.git" + }, + "dependencies": { + "@antv/util": "~3.3.0", + "fecha": "~4.2.0", + "@babel/runtime": "^7.12.5" + } +} diff --git a/packages/scale/src/base.ts b/packages/scale/src/base.ts new file mode 100644 index 000000000..45ea53fd5 --- /dev/null +++ b/packages/scale/src/base.ts @@ -0,0 +1,150 @@ +import { assign, isEmpty, isFunction, isNil, isNumber, isObject, isString, map } from '@antv/util'; +import { getTickMethod } from './tick-method/register'; +import { ScaleConfig, Tick } from './types'; +export default abstract class Scale { + /** + * 度量的类型 + */ + public type: string = 'base'; + /** + * 是否分类类型的度量 + */ + public isCategory?: boolean = false; + /** + * 是否线性度量,有linear, time 度量 + */ + public isLinear?: boolean = false; + /** + * 是否连续类型的度量,linear,time,log, pow, quantile, quantize 都支持 + */ + public isContinuous?: boolean = false; + /** + * 是否是常量的度量,传入和传出一致 + */ + public isIdentity: boolean = false; + + public field?: ScaleConfig['field']; + public alias?: ScaleConfig['alias']; + public values: ScaleConfig['values'] = []; + public min?: ScaleConfig['min']; + public max?: ScaleConfig['max']; + public minLimit?: ScaleConfig['minLimit']; + public maxLimit?: ScaleConfig['maxLimit']; + public range: ScaleConfig['range'] = [0, 1]; + public ticks: ScaleConfig['ticks'] = []; + public tickCount: ScaleConfig['tickCount']; + public tickInterval: ScaleConfig['tickInterval']; + public formatter?: ScaleConfig['formatter']; + public tickMethod?: ScaleConfig['tickMethod']; + protected __cfg__: ScaleConfig; // 缓存的旧配置, 用于 clone + + constructor(cfg: ScaleConfig) { + this.__cfg__ = cfg; + this.initCfg(); + this.init(); + } + + // 对于原始值的必要转换,如分类、时间字段需转换成数值,用transform/map命名可能更好 + public translate(v: any) { + return v; + } + + /** 将定义域转换为值域 */ + public abstract scale(value: any): number; + + /** 将值域转换为定义域 */ + public abstract invert(scaled: number): any; + + /** 重新初始化 */ + public change(cfg: ScaleConfig) { + // 覆盖配置项,而不替代 + assign(this.__cfg__, cfg); + this.init(); + } + + public clone(): Scale { + return this.constructor(this.__cfg__); + } + + /** 获取坐标轴需要的ticks */ + public getTicks(): Tick[] { + return map(this.ticks, (tick: any, idx: number) => { + if (isObject(tick)) { + // 仅当符合Tick类型时才有意义 + return tick as Tick; + } + return { + text: this.getText(tick, idx), + tickValue: tick, // 原始value + value: this.scale(tick), // scaled + }; + }); + } + + /** 获取Tick的格式化结果 */ + public getText(value: any, key?: number): string { + const formatter = this.formatter; + const res = formatter ? formatter(value, key) : value; + if (isNil(res) || !isFunction(res.toString)) { + return ''; + } + return res.toString(); + } + + // 获取配置项中的值,当前 scale 上的值可能会被修改 + protected getConfig(key) { + return this.__cfg__[key]; + } + + // scale初始化 + protected init(): void { + assign(this, this.__cfg__); + this.setDomain(); + if (isEmpty(this.getConfig('ticks'))) { + this.ticks = this.calculateTicks(); + } + } + + // 子类上覆盖某些属性,不能直接在类上声明,否则会被覆盖 + protected initCfg() {} + + protected setDomain(): void {} + + protected calculateTicks(): any[] { + const tickMethod = this.tickMethod; + let ticks = []; + if (isString(tickMethod)) { + const method = getTickMethod(tickMethod); + if (!method) { + throw new Error('There is no method to to calculate ticks!'); + } + ticks = method(this); + } else if (isFunction(tickMethod)) { + ticks = tickMethod(this); + } + return ticks; + } + + // range 的最小值 + protected rangeMin() { + return this.range[0]; + } + + // range 的最大值 + protected rangeMax() { + return this.range[1]; + } + + /** 定义域转 0~1 */ + protected calcPercent(value: any, min: number, max: number): number { + if (isNumber(value)) { + return (value - min) / (max - min); + } + return NaN; + } + + /** 0~1转定义域 */ + protected calcValue(percent: number, min: number, max: number): number { + return min + percent * (max - min); + } +} diff --git a/packages/scale/src/category/base.ts b/packages/scale/src/category/base.ts new file mode 100644 index 000000000..f0c9681d9 --- /dev/null +++ b/packages/scale/src/category/base.ts @@ -0,0 +1,87 @@ +import { indexOf, isNil, isNumber } from '@antv/util'; +import Base from '../base'; + +/** + * 分类度量 + * @class + */ +class Category extends Base { + public readonly type: string = 'cat'; + public readonly isCategory: boolean = true; + + // 用于缓存 translate 函数 + private translateIndexMap; + + private buildIndexMap() { + if (!this.translateIndexMap) { + this.translateIndexMap = new Map(); + // 重新构建缓存 + for (let i = 0; i < this.values.length; i ++) { + this.translateIndexMap.set(this.values[i], i); + } + } + } + + public translate(value: any): number { + // 按需构建 map + this.buildIndexMap(); + // 找得到 + let idx = this.translateIndexMap.get(value); + + if (idx === undefined) { + idx = isNumber(value) ? value : NaN; + } + return idx; + } + + public scale(value: any): number { + const order = this.translate(value); + // 分类数据允许 0.5 范围内调整 + // if (order < this.min - 0.5 || order > this.max + 0.5) { + // return NaN; + // } + const percent = this.calcPercent(order, this.min, this.max); + return this.calcValue(percent, this.rangeMin(), this.rangeMax()); + } + + public invert(scaledValue: number) { + const domainRange = this.max - this.min; + const percent = this.calcPercent(scaledValue, this.rangeMin(), this.rangeMax()); + const idx = Math.round(domainRange * percent) + this.min; + if (idx < this.min || idx > this.max) { + return NaN; + } + return this.values[idx]; + } + + public getText(value: any, ...args: any[]): string { + let v = value; + // value为index + if (isNumber(value) && !this.values.includes(value)) { + v = this.values[v]; + } + return super.getText(v, ...args); + } + // 复写属性 + protected initCfg() { + this.tickMethod = 'cat'; + } + // 设置 min, max + protected setDomain() { + // 用户有可能设置 min + if (isNil(this.getConfig('min'))) { + this.min = 0; + } + if (isNil(this.getConfig('max'))) { + const size = this.values.length; + this.max = size > 1 ? size - 1 : size; + } + + // scale.init 的时候清除缓存 + if (this.translateIndexMap) { + this.translateIndexMap = undefined; + } + } +} + +export default Category; diff --git a/packages/scale/src/category/time.ts b/packages/scale/src/category/time.ts new file mode 100644 index 000000000..75ebdcde9 --- /dev/null +++ b/packages/scale/src/category/time.ts @@ -0,0 +1,61 @@ +import { each, isNumber } from '@antv/util'; +import { timeFormat, toTimeStamp } from '../util/time'; +import Category from './base'; + +/** + * 时间分类度量 + * @class + */ +class TimeCat extends Category { + public readonly type: string = 'timeCat'; + public mask; + /** + * @override + */ + public translate(value) { + value = toTimeStamp(value); + let index = this.values.indexOf(value); + if (index === -1) { + if (isNumber(value) && value < this.values.length) { + index = value; + } else { + index = NaN; + } + } + return index; + } + + /** + * 由于时间类型数据需要转换一下,所以复写 getText + * @override + */ + public getText(value: string | number, tickIndex?: number) { + const index = this.translate(value); + if (index > -1) { + let result = this.values[index]; + const formatter = this.formatter; + result = formatter ? formatter(result, tickIndex) : timeFormat(result, this.mask); + return result; + } + return value; + } + protected initCfg() { + this.tickMethod = 'time-cat'; + this.mask = 'YYYY-MM-DD'; + this.tickCount = 7; // 一般时间数据会显示 7, 14, 30 天的数字 + } + + protected setDomain() { + const values = this.values; + // 针对时间分类类型,会将时间统一转换为时间戳 + each(values, (v, i) => { + values[i] = toTimeStamp(v); + }); + values.sort((v1, v2) => { + return v1 - v2; + }); + super.setDomain(); + } +} + +export default TimeCat; diff --git a/packages/scale/src/continuous/base.ts b/packages/scale/src/continuous/base.ts new file mode 100644 index 000000000..6fa41ac93 --- /dev/null +++ b/packages/scale/src/continuous/base.ts @@ -0,0 +1,82 @@ +import { filter, getRange, head, isNil, last } from '@antv/util'; +import Base from '../base'; + +/** + * 连续度量的基类 + * @class + */ +export default abstract class Continuous extends Base { + public isContinuous?: boolean = true; + public nice: boolean; + + public scale(value: any): number { + if (isNil(value)) { + return NaN; + } + const rangeMin = this.rangeMin(); + const rangeMax = this.rangeMax(); + const max = this.max; + const min = this.min; + if (max === min) { + return rangeMin; + } + const percent = this.getScalePercent(value); + return rangeMin + percent * (rangeMax - rangeMin); + } + + protected init() { + super.init(); + // init 完成后保证 min, max 包含 ticks 的范围 + const ticks = this.ticks; + const firstTick = head(ticks); + const lastTick = last(ticks); + if (firstTick < this.min) { + this.min = firstTick; + } + if (lastTick > this.max) { + this.max = lastTick; + } + // strict-limit 方式 + if (!isNil(this.minLimit)) { + this.min = firstTick; + } + if (!isNil(this.maxLimit)) { + this.max = lastTick; + } + } + + protected setDomain() { + const { min, max } = getRange(this.values); + if (isNil(this.min)) { + this.min = min; + } + if (isNil(this.max)) { + this.max = max; + } + if (this.min > this.max) { + this.min = min; + this.max = max; + } + } + + protected calculateTicks(): any[] { + let ticks = super.calculateTicks(); + if (!this.nice) { + ticks = filter(ticks, (tick) => { + return tick >= this.min && tick <= this.max; + }); + } + return ticks; + } + + // 计算原始值值占的百分比 + protected getScalePercent(value) { + const max = this.max; + const min = this.min; + return (value - min) / (max - min); + } + + protected getInvertPercent(value) { + return (value - this.rangeMin()) / (this.rangeMax() - this.rangeMin()); + } +} diff --git a/packages/scale/src/continuous/linear.ts b/packages/scale/src/continuous/linear.ts new file mode 100644 index 000000000..e58b2dfaa --- /dev/null +++ b/packages/scale/src/continuous/linear.ts @@ -0,0 +1,21 @@ +import Continuous from './base'; + +/** + * 线性度量 + * @class + */ +export default class Linear extends Continuous { + public minTickInterval: number; + public type = 'linear'; + public readonly isLinear: boolean = true; + + public invert(value: number): any { + const percent = this.getInvertPercent(value); + return this.min + percent * (this.max - this.min); + } + + protected initCfg() { + this.tickMethod = 'wilkinson-extended'; + this.nice = false; + } +} diff --git a/packages/scale/src/continuous/log.ts b/packages/scale/src/continuous/log.ts new file mode 100644 index 000000000..e06241aeb --- /dev/null +++ b/packages/scale/src/continuous/log.ts @@ -0,0 +1,87 @@ +import { getLogPositiveMin, log } from '../util/math'; +import Continuous from './base'; +/** + * Log 度量,处理非均匀分布 + */ +class Log extends Continuous { + public readonly type: string = 'log'; + public base: number; + // 用于解决 min: 0 的场景下的问题 + private positiveMin: number; + + /** + * @override + */ + public invert(value: number) { + const base = this.base; + const max = log(base, this.max); + const rangeMin = this.rangeMin(); + const range = this.rangeMax() - rangeMin; + let min; + const positiveMin = this.positiveMin; + if (positiveMin) { + if (value === 0) { + return 0; + } + min = log(base, positiveMin / base); + const appendPercent = (1 / (max - min)) * range; // 0 到 positiveMin的占比 + if (value < appendPercent) { + // 落到 0 - positiveMin 之间 + return (value / appendPercent) * positiveMin; + } + } else { + min = log(base, this.min); + } + const percent = (value - rangeMin) / range; + const tmp = percent * (max - min) + min; + return Math.pow(base, tmp); + } + + protected initCfg() { + this.tickMethod = 'log'; + this.base = 10; + this.tickCount = 6; + this.nice = true; + } + + // 设置 + protected setDomain() { + super.setDomain(); + const min = this.min; + if (min < 0) { + throw new Error('When you use log scale, the minimum value must be greater than zero!'); + } + if (min === 0) { + this.positiveMin = getLogPositiveMin(this.values, this.base, this.max); + } + } + + // 根据当前值获取占比 + protected getScalePercent(value: number) { + const max = this.max; + let min = this.min; + if (max === min) { + return 0; + } + // 如果值小于等于0,则按照0处理 + if (value <= 0) { + return 0; + } + const base = this.base; + const positiveMin = this.positiveMin; + // 如果min == 0, 则根据比0大的最小值,计算比例关系。这个最小值作为坐标轴上的第二个tick,第一个是0但是不显示 + if (positiveMin) { + min = (positiveMin * 1) / base; + } + let percent; + // 如果数值小于次小值,那么就计算 value / 次小值 占整体的比例 + if (value < positiveMin) { + percent = value / positiveMin / (log(base, max) - log(base, min)); + } else { + percent = (log(base, value) - log(base, min)) / (log(base, max) - log(base, min)); + } + return percent; + } +} + +export default Log; diff --git a/packages/scale/src/continuous/pow.ts b/packages/scale/src/continuous/pow.ts new file mode 100644 index 000000000..989e11170 --- /dev/null +++ b/packages/scale/src/continuous/pow.ts @@ -0,0 +1,48 @@ +import { calBase } from '../util/math'; +import Continuous from './base'; + +/** + * Pow 度量,处理非均匀分布 + */ +class Pow extends Continuous { + public readonly type: string = 'pow'; + /** + * 指数 + */ + public exponent: number; + + /** + * @override + */ + public invert(value: number) { + const percent = this.getInvertPercent(value); + const exponent = this.exponent; + const max = calBase(exponent, this.max); + const min = calBase(exponent, this.min); + const tmp = percent * (max - min) + min; + const factor = tmp >= 0 ? 1 : -1; + return Math.pow(tmp, exponent) * factor; + } + + protected initCfg() { + this.tickMethod = 'pow'; + this.exponent = 2; + this.tickCount = 5; + this.nice = true; + } + + // 获取度量计算时,value占的定义域百分比 + protected getScalePercent(value: number) { + const max = this.max; + const min = this.min; + if (max === min) { + return 0; + } + const exponent = this.exponent; + const percent = + (calBase(exponent, value) - calBase(exponent, min)) / (calBase(exponent, max) - calBase(exponent, min)); + return percent; + } +} + +export default Pow; diff --git a/packages/scale/src/continuous/quantile.ts b/packages/scale/src/continuous/quantile.ts new file mode 100644 index 000000000..6d5bc90ed --- /dev/null +++ b/packages/scale/src/continuous/quantile.ts @@ -0,0 +1,12 @@ +import Quantize from './quantize'; + +class Quantile extends Quantize { + public type = 'quantile'; + protected initCfg() { + this.tickMethod = 'quantile'; + this.tickCount = 5; + this.nice = true; + } +} + +export default Quantile; diff --git a/packages/scale/src/continuous/quantize.ts b/packages/scale/src/continuous/quantize.ts new file mode 100644 index 000000000..71eebc61f --- /dev/null +++ b/packages/scale/src/continuous/quantize.ts @@ -0,0 +1,73 @@ +import { each, head, last } from '@antv/util'; +import Continuous from './base'; + +/** + * 分段度量 + */ +class Quantize extends Continuous { + public type = 'quantize'; + + public invert(value): number { + const ticks = this.ticks; + const length = ticks.length; + const percent = this.getInvertPercent(value); + const minIndex = Math.floor(percent * (length - 1)); + // 最后一个 + if (minIndex >= length - 1) { + return last(ticks); + } + // 超出左边界, 则取第一个 + if (minIndex < 0) { + return head(ticks); + } + const minTick = ticks[minIndex]; + const nextTick = ticks[minIndex + 1]; + // 比当前值小的 tick 在度量上的占比 + const minIndexPercent = minIndex / (length - 1); + const maxIndexPercent = (minIndex + 1) / (length - 1); + return minTick + (percent - minIndexPercent) / (maxIndexPercent - minIndexPercent) * (nextTick - minTick); + } + + protected initCfg() { + this.tickMethod = 'r-pretty'; + this.tickCount = 5; + this.nice = true; + } + + protected calculateTicks(): any[] { + const ticks = super.calculateTicks(); + if (!this.nice) { // 如果 nice = false ,补充 min, max + if (last(ticks) !== this.max) { + ticks.push(this.max); + } + if (head(ticks) !== this.min) { + ticks.unshift(this.min); + } + } + return ticks; + } + + // 计算当前值在刻度中的占比 + protected getScalePercent(value) { + const ticks = this.ticks; + // 超出左边界 + if (value < head(ticks)) { + return 0; + } + // 超出右边界 + if (value > last(ticks)) { + return 1; + } + let minIndex = 0; + each(ticks, (tick, index) => { + if (value >= tick) { + minIndex = index; + } else { + return false; + } + }); + return minIndex / (ticks.length - 1); + } +} + +export default Quantize; diff --git a/packages/scale/src/continuous/time.ts b/packages/scale/src/continuous/time.ts new file mode 100644 index 000000000..a0e465de5 --- /dev/null +++ b/packages/scale/src/continuous/time.ts @@ -0,0 +1,94 @@ +import { each, isDate, isNil, isNumber, isString } from '@antv/util'; +import { timeFormat, toTimeStamp } from '../util/time'; +import Linear from './linear'; + +/** + * 时间度量 + * @class + */ +class Time extends Linear { + public readonly type: string = 'time'; + public mask: string; + + /** + * @override + */ + public getText(value: string | number | Date, index?: number) { + const numberValue = this.translate(value); + const formatter = this.formatter; + return formatter ? formatter(numberValue, index) : timeFormat(numberValue, this.mask); + } + /** + * @override + */ + public scale(value): number { + let v = value; + if (isString(v) || isDate(v)) { + v = this.translate(v); + } + return super.scale(v); + } + /** + * 将时间转换成数字 + * @override + */ + public translate(v): number { + return toTimeStamp(v); + } + protected initCfg() { + this.tickMethod = 'time-pretty'; + this.mask = 'YYYY-MM-DD'; + this.tickCount = 7; + this.nice = false; + } + + protected setDomain() { + const values = this.values; + // 是否设置了 min, max,而不是直接取 this.min, this.max + const minConfig = this.getConfig('min'); + const maxConfig = this.getConfig('max'); + // 如果设置了 min,max 则转换成时间戳 + if (!isNil(minConfig) || !isNumber(minConfig)) { + this.min = this.translate(this.min); + } + if (!isNil(maxConfig) || !isNumber(maxConfig)) { + this.max = this.translate(this.max); + } + // 没有设置 min, max 时 + if (values && values.length) { + // 重新计算最大最小值 + const timeStamps = []; + let min = Infinity; // 最小值 + let secondMin = min; // 次小值 + let max = 0; + // 使用一个循环,计算min,max,secondMin + each(values, (v) => { + const timeStamp = toTimeStamp(v); + if (isNaN(timeStamp)) { + throw new TypeError(`Invalid Time: ${v} in time scale!`); + } + if (min > timeStamp) { + secondMin = min; + min = timeStamp; + } else if (secondMin > timeStamp) { + secondMin = timeStamp; + } + if (max < timeStamp) { + max = timeStamp; + } + timeStamps.push(timeStamp); + }); + // 存在多个值时,设置最小间距 + if (values.length > 1) { + this.minTickInterval = secondMin - min; + } + if (isNil(minConfig)) { + this.min = min; + } + if (isNil(maxConfig)) { + this.max = max; + } + } + } +} +export default Time; diff --git a/packages/scale/src/factory.ts b/packages/scale/src/factory.ts new file mode 100644 index 000000000..26c18fe91 --- /dev/null +++ b/packages/scale/src/factory.ts @@ -0,0 +1,22 @@ +import Scale from './base'; +import { ScaleConfig } from './types'; +type ScaleConstructor = new (cfg: ScaleConfig) => T; + +interface ScaleMap { + [key: string]: ScaleConstructor; +} + +const map: ScaleMap = {}; + +function getClass(key: string): ScaleConstructor { + return map[key]; +} + +function registerClass(key: string, cls: ScaleConstructor) { + if (getClass(key)) { + throw new Error(`type '${key}' existed.`); + } + map[key] = cls; +} + +export { Scale, getClass as getScale, registerClass as registerScale }; diff --git a/packages/scale/src/identity/index.ts b/packages/scale/src/identity/index.ts new file mode 100644 index 000000000..a24e7fa00 --- /dev/null +++ b/packages/scale/src/identity/index.ts @@ -0,0 +1,33 @@ +import { has, isNumber } from '@antv/util'; +import Base from '../base'; +import { ScaleType } from '../types'; + +/** + * identity scale原则上是定义域和值域一致,scale/invert方法也是一致的 + * 参考R的实现:/~https://github.com/r-lib/scales/blob/master/R/pal-identity.r + * 参考d3的实现(做了下转型):/~https://github.com/d3/d3-scale/blob/master/src/identity.js + */ +export default class Identity extends Base { + public readonly type: ScaleType = 'identity'; + public readonly isIdentity: boolean = true; + + public calculateTicks() { + return this.values; + } + + public scale(value: any): number { + // 如果传入的值不等于 identity 的值,则直接返回,用于一维图时的 dodge + if (this.values[0] !== value && isNumber(value)) { + return value; + } + return this.range[0]; + } + + public invert(value?: number): number { + const range = this.range; + if (value < range[0] || value > range[1]) { + return NaN; + } + return this.values[0]; + } +} diff --git a/packages/scale/src/index.ts b/packages/scale/src/index.ts new file mode 100644 index 000000000..f1f93aff5 --- /dev/null +++ b/packages/scale/src/index.ts @@ -0,0 +1,43 @@ +import Scale from './base'; +import Category from './category/base'; +import TimeCat from './category/time'; +import Linear from './continuous/linear'; +import Log from './continuous/log'; +import Pow from './continuous/pow'; +import Time from './continuous/time'; +import Quantize from './continuous/quantize'; +import Quantile from './continuous/quantile'; +import { getScale, registerScale } from './factory'; +import Identity from './identity/index'; +import { getTickMethod, registerTickMethod } from './tick-method/index'; +import { ScaleConfig, Tick } from './types'; + +registerScale('cat', Category); +registerScale('category', Category); +registerScale('identity', Identity); +registerScale('linear', Linear); +registerScale('log', Log); +registerScale('pow', Pow); +registerScale('time', Time); +registerScale('timeCat', TimeCat); +registerScale('quantize', Quantize); +registerScale('quantile', Quantile); + +export { + Category, + Identity, + Linear, + Log, + Pow, + Time, + TimeCat, + Quantile, + Quantize, + Scale, + getScale, + registerScale, + ScaleConfig, + Tick, + getTickMethod, + registerTickMethod, +}; diff --git a/packages/scale/src/interfaces.ts b/packages/scale/src/interfaces.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/scale/src/tick-method/cat.ts b/packages/scale/src/tick-method/cat.ts new file mode 100644 index 000000000..2f6f81afa --- /dev/null +++ b/packages/scale/src/tick-method/cat.ts @@ -0,0 +1,45 @@ +import { filter, isNil, isNumber, last } from '@antv/util'; +import { ScaleConfig } from '../types'; + +/** + * 计算分类 ticks + * @param cfg 度量的配置项 + * @returns 计算后的 ticks + */ +export default function calculateCatTicks(cfg: ScaleConfig): any[] { + const { values, tickInterval, tickCount, showLast } = cfg; + + if (isNumber(tickInterval)) { + const ticks = filter(values, (__: any, i: number) => i % tickInterval === 0); + const lastValue = last(values); + if (showLast && last(ticks) !== lastValue) { + ticks.push(lastValue); + } + return ticks; + } + + const len = values.length; + let { min, max } = cfg; + if (isNil(min)) { + min = 0; + } + if (isNil(max)) { + max = values.length - 1; + } + + if (!isNumber(tickCount) || tickCount >= len) return values.slice(min, max + 1); + if (tickCount <= 0 || max <= 0) return []; + + const interval = tickCount === 1 ? len : Math.floor(len / (tickCount - 1)); + const ticks = []; + + let idx = min; + for (let i = 0; i < tickCount; i++) { + if (idx >= max) break; + + idx = Math.min(min + i * interval, max); + if (i === tickCount - 1 && showLast) ticks.push(values[max]); + else ticks.push(values[idx]); + } + return ticks; +} diff --git a/packages/scale/src/tick-method/d3-linear.ts b/packages/scale/src/tick-method/d3-linear.ts new file mode 100644 index 000000000..d869febbf --- /dev/null +++ b/packages/scale/src/tick-method/d3-linear.ts @@ -0,0 +1,18 @@ +import { head, isNil, last } from '@antv/util'; +import { ScaleConfig } from '../types'; +import d3Linear from '../util/d3-linear'; +import interval from '../util/interval'; +import strictLimit from '../util/strict-limit'; + +export default function d3LinearTickMethod(cfg: ScaleConfig): number[] { + const { min, max, tickInterval, minLimit, maxLimit } = cfg; + const ticks = d3Linear(cfg); + + if (!isNil(minLimit) || !isNil(maxLimit)) { + return strictLimit(cfg, head(ticks), last(ticks)); + } + if (tickInterval) { + return interval(min, max, tickInterval).ticks; + } + return ticks; +} diff --git a/packages/scale/src/tick-method/index.ts b/packages/scale/src/tick-method/index.ts new file mode 100644 index 000000000..965fe6bdb --- /dev/null +++ b/packages/scale/src/tick-method/index.ts @@ -0,0 +1,24 @@ +import cat from './cat'; +import d3Linear from './d3-linear'; +import linear from './linear'; +import log from './log'; +import pow from './pow'; +import quantile from './quantile'; +import rPretty from './r-prettry'; +import { getTickMethod, registerTickMethod } from './register'; +import time from './time'; +import timeCat from './time-cat'; +import timePretty from './time-pretty'; + +registerTickMethod('cat', cat); +registerTickMethod('time-cat', timeCat); +registerTickMethod('wilkinson-extended', linear); +registerTickMethod('r-pretty', rPretty); +registerTickMethod('time', time); +registerTickMethod('time-pretty', timePretty); +registerTickMethod('log', log); +registerTickMethod('pow', pow); +registerTickMethod('quantile', quantile); +registerTickMethod('d3-linear', d3Linear); + +export { getTickMethod, registerTickMethod }; diff --git a/packages/scale/src/tick-method/linear.ts b/packages/scale/src/tick-method/linear.ts new file mode 100644 index 000000000..6c633ed1d --- /dev/null +++ b/packages/scale/src/tick-method/linear.ts @@ -0,0 +1,23 @@ +import { head, isNil, last } from '@antv/util'; +import { ScaleConfig } from '../types'; +import extended from '../util/extended'; +import interval from '../util/interval'; +import strictLimit from '../util/strict-limit'; + +/** + * 计算线性的 ticks,使用 wilkinson extended 方法 + * @param cfg 度量的配置项 + * @returns 计算后的 ticks + */ +export default function linear(cfg: ScaleConfig): number[] { + const { min, max, tickCount, nice, tickInterval, minLimit, maxLimit } = cfg; + const ticks = extended(min, max, tickCount, nice).ticks; + + if (!isNil(minLimit) || !isNil(maxLimit)) { + return strictLimit(cfg, head(ticks), last(ticks)); + } + if (tickInterval) { + return interval(min, max, tickInterval).ticks; + } + return ticks; +} diff --git a/packages/scale/src/tick-method/log.ts b/packages/scale/src/tick-method/log.ts new file mode 100644 index 000000000..0fb7a333f --- /dev/null +++ b/packages/scale/src/tick-method/log.ts @@ -0,0 +1,31 @@ +import { isEmpty } from '@antv/util'; +import { ScaleConfig } from '../types'; +import { getLogPositiveMin, log } from '../util/math'; + +/** + * 计算 log 的 ticks,考虑 min = 0 的场景 + * @param cfg 度量的配置项 + * @returns 计算后的 ticks + */ +export default function calculateLogTicks(cfg: ScaleConfig) { + const { base, tickCount, min, max, values } = cfg; + let minTick; + const maxTick = log(base, max); + if (min > 0) { + minTick = Math.floor(log(base, min)); + } else { + const positiveMin = getLogPositiveMin(values, base, max); + minTick = Math.floor(log(base, positiveMin)); + } + const count = maxTick - minTick; + const avg = Math.ceil(count / tickCount); + const ticks = []; + for (let i = minTick; i < maxTick + avg; i = i + avg) { + ticks.push(Math.pow(base, i)); + } + if (min <= 0) { + // 最小值 <= 0 时显示 0 + ticks.unshift(0); + } + return ticks; +} diff --git a/packages/scale/src/tick-method/pow.ts b/packages/scale/src/tick-method/pow.ts new file mode 100644 index 000000000..2464e96ac --- /dev/null +++ b/packages/scale/src/tick-method/pow.ts @@ -0,0 +1,18 @@ +import { ScaleConfig } from '../types'; +import { calBase } from '../util/math'; +import pretty from '../util/pretty'; +/** + * 计算 Pow 的 ticks + * @param cfg 度量的配置项 + * @returns 计算后的 ticks + */ +export default function calculatePowTicks(cfg: ScaleConfig) { + const { exponent, tickCount } = cfg; + const max = Math.ceil(calBase(exponent, cfg.max)); + const min = Math.floor(calBase(exponent, cfg.min)); + const ticks = pretty(min, max, tickCount).ticks; + return ticks.map((tick) => { + const factor = tick >= 0 ? 1 : -1; + return Math.pow(tick, exponent) * factor; + }); +} diff --git a/packages/scale/src/tick-method/quantile.ts b/packages/scale/src/tick-method/quantile.ts new file mode 100644 index 000000000..294600d07 --- /dev/null +++ b/packages/scale/src/tick-method/quantile.ts @@ -0,0 +1,49 @@ +import { ScaleConfig } from '../types'; +/** + * 计算几分位 /~https://github.com/simple-statistics/simple-statistics/blob/master/src/quantile_sorted.js + * @param x 数组 + * @param p 百分比 + */ +function quantileSorted(x, p) { + const idx = x.length * p; + /*if (x.length === 0) { // 当前场景这些条件不可能命中 + throw new Error('quantile requires at least one value.'); + } else if (p < 0 || p > 1) { + throw new Error('quantiles must be between 0 and 1'); + } else */ + + if (p === 1) { + // If p is 1, directly return the last element + return x[x.length - 1]; + } else if (p === 0) { + // If p is 0, directly return the first element + return x[0]; + } else if (idx % 1 !== 0) { + // If p is not integer, return the next element in array + return x[Math.ceil(idx) - 1]; + } else if (x.length % 2 === 0) { + // If the list has even-length, we'll take the average of this number + // and the next value, if there is one + return (x[idx - 1] + x[idx]) / 2; + } else { + // Finally, in the simple case of an integer value + // with an odd-length list, return the x value at the index. + return x[idx]; + } +} + +export default function calculateTicks(cfg: ScaleConfig) { + const { tickCount, values } = cfg; + if (!values || !values.length) { + return []; + } + const sorted = values.slice().sort((a, b) => { + return a - b; + }); + const ticks = []; + for (let i = 0; i < tickCount; i++) { + const p = i / (tickCount - 1); + ticks.push(quantileSorted(sorted, p)); + } + return ticks; +} diff --git a/packages/scale/src/tick-method/r-prettry.ts b/packages/scale/src/tick-method/r-prettry.ts new file mode 100644 index 000000000..f3eae45cd --- /dev/null +++ b/packages/scale/src/tick-method/r-prettry.ts @@ -0,0 +1,23 @@ +import { head, isNil, last } from '@antv/util'; +import { ScaleConfig } from '../types'; +import interval from '../util/interval'; +import pretty from '../util/pretty'; +import strictLimit from '../util/strict-limit'; + +/** + * 计算线性的 ticks,使用 R's pretty 方法 + * @param cfg 度量的配置项 + * @returns 计算后的 ticks + */ +export default function linearPretty(cfg: ScaleConfig): number[] { + const { min, max, tickCount, tickInterval, minLimit, maxLimit } = cfg; + const ticks = pretty(min, max, tickCount).ticks; + + if (!isNil(minLimit) || !isNil(maxLimit)) { + return strictLimit(cfg, head(ticks), last(ticks)); + } + if (tickInterval) { + return interval(min, max, tickInterval).ticks; + } + return ticks; +} diff --git a/packages/scale/src/tick-method/register.ts b/packages/scale/src/tick-method/register.ts new file mode 100644 index 000000000..7db3fda39 --- /dev/null +++ b/packages/scale/src/tick-method/register.ts @@ -0,0 +1,23 @@ +import { TickMethod } from '../types'; +interface MethodMap { + [key: string]: TickMethod; +} +const methodCache: MethodMap = {}; + +/** + * 获取计算 ticks 的方法 + * @param key 键值 + * @returns 计算 ticks 的方法 + */ +export function getTickMethod(key: string): TickMethod { + return methodCache[key]; +} + +/** + * 注册计算 ticks 的方法 + * @param key 键值 + * @param method 方法 + */ +export function registerTickMethod(key: string, method: TickMethod) { + methodCache[key] = method; +} diff --git a/packages/scale/src/tick-method/time-cat.ts b/packages/scale/src/tick-method/time-cat.ts new file mode 100644 index 000000000..498f41a26 --- /dev/null +++ b/packages/scale/src/tick-method/time-cat.ts @@ -0,0 +1,12 @@ +import { ScaleConfig } from '../types'; +import catTicks from './cat'; +/** + * 计算时间分类的 ticks, 保头,保尾 + * @param cfg 度量的配置项 + * @returns 计算后的 ticks + */ +export default function timeCat(cfg: ScaleConfig): any[] { + // 默认保留最后一条 + const ticks = catTicks({ showLast: true, ...cfg }); + return ticks; +} diff --git a/packages/scale/src/tick-method/time-pretty.ts b/packages/scale/src/tick-method/time-pretty.ts new file mode 100644 index 000000000..288f674d7 --- /dev/null +++ b/packages/scale/src/tick-method/time-pretty.ts @@ -0,0 +1,125 @@ +import { ScaleConfig } from '../types'; +import { DAY, getTickInterval, HOUR, MINUTE, MONTH, SECOND, YEAR } from '../util/time'; + +function getYear(date: number) { + return new Date(date).getFullYear(); +} + +function createYear(year: number) { + return new Date(year, 0, 1).getTime(); +} + +function getMonth(date: number) { + return new Date(date).getMonth(); +} + +function diffMonth(min: number, max: number) { + const minYear = getYear(min); + const maxYear = getYear(max); + const minMonth = getMonth(min); + const maxMonth = getMonth(max); + return (maxYear - minYear) * 12 + ((maxMonth - minMonth) % 12); +} + +function creatMonth(year: number, month: number) { + return new Date(year, month, 1).getTime(); +} + +function diffDay(min: number, max: number) { + return Math.ceil((max - min) / DAY); +} + +function diffHour(min: number, max: number) { + return Math.ceil((max - min) / HOUR); +} + +function diffMinus(min: number, max: number) { + return Math.ceil((max - min) / (60 * 1000)); +} + +/** + * 计算 time 的 ticks,对 month, year 进行 pretty 处理 + * @param cfg 度量的配置项 + * @returns 计算后的 ticks + */ +export default function timePretty(cfg: ScaleConfig): number[] { + const { min, max, minTickInterval, tickCount } = cfg; + let { tickInterval } = cfg; + const ticks: number[] = []; + // 指定 tickInterval 后 tickCount 不生效,需要重新计算 + if (!tickInterval) { + tickInterval = (max - min) / tickCount; + // 如果设置了最小间距,则使用最小间距 + if (minTickInterval && tickInterval < minTickInterval) { + tickInterval = minTickInterval; + } + } + tickInterval = Math.max(Math.floor((max - min) / (2 ** 12 - 1)), tickInterval); + const minYear = getYear(min); + // 如果间距大于 1 年,则将开始日期从整年开始 + if (tickInterval > YEAR) { + const maxYear = getYear(max); + const yearInterval = Math.ceil(tickInterval / YEAR); + for (let i = minYear; i <= maxYear + yearInterval; i = i + yearInterval) { + ticks.push(createYear(i)); + } + } else if (tickInterval > MONTH) { + // 大于月时 + const monthInterval = Math.ceil(tickInterval / MONTH); + const mmMoth = getMonth(min); + const dMonths = diffMonth(min, max); + for (let i = 0; i <= dMonths + monthInterval; i = i + monthInterval) { + ticks.push(creatMonth(minYear, i + mmMoth)); + } + } else if (tickInterval > DAY) { + // 大于天 + const date = new Date(min); + const year = date.getFullYear(); + const month = date.getMonth(); + const mday = date.getDate(); + const day = Math.ceil(tickInterval / DAY); + const ddays = diffDay(min, max); + for (let i = 0; i < ddays + day; i = i + day) { + ticks.push(new Date(year, month, mday + i).getTime()); + } + } else if (tickInterval > HOUR) { + // 大于小时 + const date = new Date(min); + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + const hour = date.getHours(); + const hours = Math.ceil(tickInterval / HOUR); + const dHours = diffHour(min, max); + for (let i = 0; i <= dHours + hours; i = i + hours) { + ticks.push(new Date(year, month, day, hour + i).getTime()); + } + } else if (tickInterval > MINUTE) { + // 大于分钟 + const dMinus = diffMinus(min, max); + const minutes = Math.ceil(tickInterval / MINUTE); + for (let i = 0; i <= dMinus + minutes; i = i + minutes) { + ticks.push(min + i * MINUTE); + } + } else { + // 小于分钟 + let interval = tickInterval; + if (interval < SECOND) { + interval = SECOND; + } + const minSecond = Math.floor(min / SECOND) * SECOND; + const dSeconds = Math.ceil((max - min) / SECOND); + const seconds = Math.ceil(interval / SECOND); + for (let i = 0; i < dSeconds + seconds; i = i + seconds) { + ticks.push(minSecond + i * SECOND); + } + } + + // 最好是能从算法能解决这个问题,但是如果指定了 tickInterval,计算 ticks,也只能这么算,所以 + // 打印警告提示 + if (ticks.length >= 512) { + console.warn(`Notice: current ticks length(${ticks.length}) >= 512, may cause performance issues, even out of memory. Because of the configure "tickInterval"(in milliseconds, current is ${tickInterval}) is too small, increase the value to solve the problem!`); + } + + return ticks; +} diff --git a/packages/scale/src/tick-method/time.ts b/packages/scale/src/tick-method/time.ts new file mode 100644 index 000000000..81326b6b9 --- /dev/null +++ b/packages/scale/src/tick-method/time.ts @@ -0,0 +1,30 @@ +import { ScaleConfig } from '../types'; +import { getTickInterval } from '../util/time'; + +export default function calculateTimeTicks(cfg: ScaleConfig): number[] { + const { min, max, minTickInterval } = cfg; + let tickInterval = cfg.tickInterval; + let tickCount = cfg.tickCount; + // 指定 tickInterval 后 tickCount 不生效,需要重新计算 + if (tickInterval) { + tickCount = Math.ceil((max - min) / tickInterval); + } else { + tickInterval = getTickInterval(min, max, tickCount)[1]; + const count = (max - min) / tickInterval; + const ratio = count / tickCount; + if (ratio > 1) { + tickInterval = tickInterval * Math.ceil(ratio); + } + // 如果设置了最小间距,则使用最小间距 + if (minTickInterval && tickInterval < minTickInterval) { + tickInterval = minTickInterval; + } + } + + tickInterval = Math.max(Math.floor((max - min) / (2 ** 12 - 1)), tickInterval); + const ticks = []; + for (let i = min; i < max + tickInterval; i += tickInterval) { + ticks.push(i); + } + return ticks; +} diff --git a/packages/scale/src/types.ts b/packages/scale/src/types.ts new file mode 100644 index 000000000..1e0636aa4 --- /dev/null +++ b/packages/scale/src/types.ts @@ -0,0 +1,58 @@ +export type ScaleType = 'base' | 'linear' | 'cat' | 'log' | 'pow' | 'identity' | 'time' | 'timeCat'; + +export type TickMethod = (ScaleConfig) => any[]; + +export interface Tick { + /** 展示名 */ + text: string; + /** 值域值 */ + value: number; + /** 定义域值 */ + tickValue: string | number; +} + +export type ScaleConfig = Partial<{ + /** 对应的字段id */ + field: string; + /** 输入域、定义域 */ + values: any[]; + /** 定义域的最小值,d3为domain,ggplot2为limits,分类型下无效 */ + min: any; + /** 定义域的最大值,分类型下无效 */ + max: any; + /** 严格模式下的定义域最小值,设置后会强制 ticks 从最小值开始 */ + minLimit?: any; + /** 严格模式下的定义域最大值,设置后会强制 ticks 已最大值结束 */ + maxLimit?: any; + + /** 数据字段的显示别名,scale内部不感知,外部注入 */ + alias: string; + /** 输出域、值域,默认值为[0, 1] */ + range: number[]; + /** Log有效,底数 */ + base: number; + /** Pow有效,指数 */ + exponent: number; + + // tick相关配置 + /** 自动调整min、max */ + nice: boolean; + /** 用于指定tick,优先级最高 */ + ticks: any[]; + /** tick间隔,只对分类型和时间型适用,优先级高于tickCount */ + tickInterval: number; + /** tick最小间隔,只对线型适用 */ + minTickInterval: number; + /** tick个数,默认值为5 */ + tickCount: number; + /** ticks最大值,默认值为10 */ + maxTickCount: number; + /** tick格式化函数,会影响数据在坐标轴 axis、图例 legend、tooltip 上的显示 */ + formatter: (value: any, index?: number) => any; + /** 计算 ticks 的算法 */ + tickMethod: string | TickMethod; + /** 时间度量 time, timeCat 时有效 */ + mask?: string; + /** 是否始终保留最后一个 tick */ + showLast?: boolean; +}>; diff --git a/packages/scale/src/util/bisector.ts b/packages/scale/src/util/bisector.ts new file mode 100644 index 000000000..e1917564a --- /dev/null +++ b/packages/scale/src/util/bisector.ts @@ -0,0 +1,28 @@ +import { isNil } from '@antv/util'; + +type GetterFunc = (o: T) => number; + +/** + * 二分右侧查找 + * /~https://github.com/d3/d3-array/blob/master/src/bisector.js + */ +export default function(getter: GetterFunc) { + /** + * x: 目标值 + * lo: 起始位置 + * hi: 结束位置 + */ + return function(a: T[], x: number, _lo?: number, _hi?: number) { + let lo = isNil(_lo) ? 0 : _lo; + let hi = isNil(_hi) ? a.length : _hi; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (getter(a[mid]) > x) { + hi = mid; + } else { + lo = mid + 1; + } + } + return lo; + }; +} diff --git a/packages/scale/src/util/d3-linear.ts b/packages/scale/src/util/d3-linear.ts new file mode 100644 index 000000000..14b7707f2 --- /dev/null +++ b/packages/scale/src/util/d3-linear.ts @@ -0,0 +1,123 @@ +import { ScaleConfig } from '../types'; + +export default function d3Linear(cfg: ScaleConfig): number[] { + const { min, max, nice, tickCount } = cfg; + const linear = new D3Linear(); + linear.domain([min, max]); + if (nice) { + linear.nice(tickCount); + } + return linear.ticks(tickCount); +} + +const DEFAULT_COUNT = 5; +const e10 = Math.sqrt(50); +const e5 = Math.sqrt(10); +const e2 = Math.sqrt(2); + +// /~https://github.com/d3/d3-scale +export class D3Linear { + private _domain: number[] = [0, 1]; + + public domain(domain?: number[]): D3Linear | number[] { + if (domain) { + this._domain = Array.from(domain, Number); + return this; + } + return this._domain.slice(); + } + + public nice(count = DEFAULT_COUNT) { + const d = this._domain.slice(); + let i0 = 0; + let i1 = this._domain.length - 1; + let start = this._domain[i0]; + let stop = this._domain[i1]; + let step; + + if (stop < start) { + [start, stop] = [stop, start]; + [i0, i1] = [i1, i0]; + } + step = tickIncrement(start, stop, count); + + if (step > 0) { + start = Math.floor(start / step) * step; + stop = Math.ceil(stop / step) * step; + step = tickIncrement(start, stop, count); + } else if (step < 0) { + start = Math.ceil(start * step) / step; + stop = Math.floor(stop * step) / step; + step = tickIncrement(start, stop, count); + } + + if (step > 0) { + d[i0] = Math.floor(start / step) * step; + d[i1] = Math.ceil(stop / step) * step; + this.domain(d); + } else if (step < 0) { + d[i0] = Math.ceil(start * step) / step; + d[i1] = Math.floor(stop * step) / step; + this.domain(d); + } + + return this; + } + + public ticks(count = DEFAULT_COUNT): number[] { + return d3ArrayTicks(this._domain[0], this._domain[this._domain.length - 1], count || DEFAULT_COUNT); + } +} + +function d3ArrayTicks(start: number, stop: number, count: number): number[] { + let reverse; + let i = -1; + let n; + let ticks; + let step; + + (stop = +stop), (start = +start), (count = +count); + if (start === stop && count > 0) { + return [start]; + } + // tslint:disable-next-line + if ((reverse = stop < start)) { + (n = start), (start = stop), (stop = n); + } + // tslint:disable-next-line + if ((step = tickIncrement(start, stop, count)) === 0 || !isFinite(step)) { + return []; + } + + if (step > 0) { + start = Math.ceil(start / step); + stop = Math.floor(stop / step); + ticks = new Array((n = Math.ceil(stop - start + 1))); + while (++i < n) { + ticks[i] = (start + i) * step; + } + } else { + start = Math.floor(start * step); + stop = Math.ceil(stop * step); + ticks = new Array((n = Math.ceil(start - stop + 1))); + while (++i < n) { + ticks[i] = (start - i) / step; + } + } + + if (reverse) { + ticks.reverse(); + } + + return ticks; +} + +function tickIncrement(start: number, stop: number, count: number): number { + const step = (stop - start) / Math.max(0, count); + const power = Math.floor(Math.log(step) / Math.LN10); + const error = step / Math.pow(10, power); + + return power >= 0 + ? (error >= e10 ? 10 : error >= e5 ? 5 : error >= e2 ? 2 : 1) * Math.pow(10, power) + : -Math.pow(10, -power) / (error >= e10 ? 10 : error >= e5 ? 5 : error >= e2 ? 2 : 1); +} diff --git a/packages/scale/src/util/extended.ts b/packages/scale/src/util/extended.ts new file mode 100644 index 000000000..0799177bb --- /dev/null +++ b/packages/scale/src/util/extended.ts @@ -0,0 +1,207 @@ +import { head, indexOf, size, last } from '@antv/util'; +import { prettyNumber } from './pretty-number'; + +export const DEFAULT_Q = [1, 5, 2, 2.5, 4, 3]; + +export const ALL_Q = [1, 5, 2, 2.5, 4, 3, 1.5, 7, 6, 8, 9]; + +const eps = Number.EPSILON * 100; + +function mod(n: number, m: number) { + return ((n % m) + m) % m; +} + +function round(n: number) { + return Math.round(n * 1e12) / 1e12; +} + +function simplicity(q: number, Q: number[], j: number, lmin: number, lmax: number, lstep: number) { + const n = size(Q); + const i = indexOf(Q, q); + let v = 0; + const m = mod(lmin, lstep); + if ((m < eps || lstep - m < eps) && lmin <= 0 && lmax >= 0) { + v = 1; + } + return 1 - i / (n - 1) - j + v; +} + +function simplicityMax(q: number, Q: number[], j: number) { + const n = size(Q); + const i = indexOf(Q, q); + const v = 1; + return 1 - i / (n - 1) - j + v; +} + +function density(k: number, m: number, dMin: number, dMax: number, lMin: number, lMax: number) { + const r = (k - 1) / (lMax - lMin); + const rt = (m - 1) / (Math.max(lMax, dMax) - Math.min(dMin, lMin)); + return 2 - Math.max(r / rt, rt / r); +} + +function densityMax(k: number, m: number) { + if (k >= m) { + return 2 - (k - 1) / (m - 1); + } + return 1; +} + +function coverage(dMin: number, dMax: number, lMin: number, lMax: number) { + const range = dMax - dMin; + return 1 - (0.5 * ((dMax - lMax) ** 2 + (dMin - lMin) ** 2)) / (0.1 * range) ** 2; +} + +function coverageMax(dMin: number, dMax: number, span: number) { + const range = dMax - dMin; + if (span > range) { + const half = (span - range) / 2; + return 1 - half ** 2 / (0.1 * range) ** 2; + } + return 1; +} + +function legibility() { + return 1; +} + +/** + * An Extension of Wilkinson's Algorithm for Position Tick Labels on Axes + * https://www.yuque.com/preview/yuque/0/2019/pdf/185317/1546999150858-45c3b9c2-4e86-4223-bf1a-8a732e8195ed.pdf + * @param dMin 最小值 + * @param dMax 最大值 + * @param m tick个数 + * @param onlyLoose 是否允许扩展min、max,不绝对强制,例如[3, 97] + * @param Q nice numbers集合 + * @param w 四个优化组件的权重 + */ +export default function extended( + dMin: number, + dMax: number, + n: number = 5, + onlyLoose: boolean = true, + Q: number[] = DEFAULT_Q, + w: [number, number, number, number] = [0.25, 0.2, 0.5, 0.05] +): { min: number; max: number; ticks: number[] } { + // 处理小于 0 和小数的 tickCount + const m = n < 0 ? 0 : Math.round(n); + + // nan 也会导致异常 + if (Number.isNaN(dMin) || Number.isNaN(dMax) || typeof dMin !== 'number' || typeof dMax !== 'number' || !m) { + return { + min: 0, + max: 0, + ticks: [], + }; + } + + // js 极大值极小值问题,差值小于 1e-15 会导致计算出错 + if (dMax - dMin < 1e-15 || m === 1) { + return { + min: dMin, + max: dMax, + ticks: [dMin], + }; + } + + // js 超大值问题 + if (dMax - dMin > 1e148) { + const count = n || 5; + const step = (dMax - dMin) / count; + return { + min: dMin, + max: dMax, + ticks: Array(count).fill(null).map((_,idx) => { + return prettyNumber(dMin + step * idx); + }), + }; + } + + const best = { + score: -2, + lmin: 0, + lmax: 0, + lstep: 0, + }; + + let j = 1; + while (j < Infinity) { + for (let i = 0; i < Q.length; i += 1) { + const q = Q[i]; + const sm = simplicityMax(q, Q, j); + if (w[0] * sm + w[1] + w[2] + w[3] < best.score) { + j = Infinity; + break; + } + let k = 2; + while (k < Infinity) { + const dm = densityMax(k, m); + if (w[0] * sm + w[1] + w[2] * dm + w[3] < best.score) { + break; + } + + const delta = (dMax - dMin) / (k + 1) / j / q; + let z = Math.ceil(Math.log10(delta)); + + while (z < Infinity) { + const step = j * q * 10 ** z; + const cm = coverageMax(dMin, dMax, step * (k - 1)); + + if (w[0] * sm + w[1] * cm + w[2] * dm + w[3] < best.score) { + break; + } + + const minStart = Math.floor(dMax / step) * j - (k - 1) * j; + const maxStart = Math.ceil(dMin / step) * j; + + if (minStart <= maxStart) { + const count = maxStart - minStart; + for (let i = 0; i <= count; i += 1) { + const start = minStart + i; + const lMin = start * (step / j); + const lMax = lMin + step * (k - 1); + const lStep = step; + + const s = simplicity(q, Q, j, lMin, lMax, lStep); + const c = coverage(dMin, dMax, lMin, lMax); + const g = density(k, m, dMin, dMax, lMin, lMax); + const l = legibility(); + + const score = w[0] * s + w[1] * c + w[2] * g + w[3] * l; + if (score > best.score && (!onlyLoose || (lMin <= dMin && lMax >= dMax))) { + best.lmin = lMin; + best.lmax = lMax; + best.lstep = lStep; + best.score = score; + } + } + } + z += 1; + } + k += 1; + } + } + j += 1; + } + + // 处理精度问题,保证这三个数没有精度问题 + const lmax = prettyNumber(best.lmax); + const lmin = prettyNumber(best.lmin); + const lstep = prettyNumber(best.lstep); + + // 加 round 是为处理 extended(0.94, 1, 5) + // 保证生成的 tickCount 没有精度问题 + const tickCount = Math.floor(round((lmax - lmin) / lstep)) + 1; + const ticks = new Array(tickCount); + + // 少用乘法:防止出现 -1.2 + 1.2 * 3 = 2.3999999999999995 的情况 + ticks[0] = prettyNumber(lmin); + for (let i = 1; i < tickCount; i++) { + ticks[i] = prettyNumber(ticks[i - 1] + lstep); + } + + return { + min: Math.min(dMin, head(ticks)), + max: Math.max(dMax, last(ticks)), + ticks, + }; +} diff --git a/packages/scale/src/util/interval.ts b/packages/scale/src/util/interval.ts new file mode 100644 index 000000000..053136a26 --- /dev/null +++ b/packages/scale/src/util/interval.ts @@ -0,0 +1,35 @@ + +import { fixedBase } from '@antv/util'; + +function snapMultiple(v, base, snapType) { + let div; + if (snapType === 'ceil') { + div = Math.ceil(v / base); + } else if (snapType === 'floor') { + div = Math.floor(v / base); + } else { + div = Math.round(v / base); + } + return div * base; +} + +export default function intervalTicks(min, max, interval) { + // 变成 interval 的倍数 + let minTick = snapMultiple(min, interval, 'floor'); + let maxTick = snapMultiple(max, interval, 'ceil'); + // 统一小数位数 + minTick = fixedBase(minTick, interval); + maxTick = fixedBase(maxTick, interval); + const ticks = []; + // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Errors/Invalid_array_length + const availableInterval = Math.max((maxTick - minTick) / (2 ** 12 - 1), interval); + for (let i = minTick; i <= maxTick; i = i + availableInterval) { + const tickValue = fixedBase(i, availableInterval); // 防止浮点数加法出现问题 + ticks.push(tickValue); + } + return { + min: minTick, + max: maxTick, + ticks + }; +} \ No newline at end of file diff --git a/packages/scale/src/util/math.ts b/packages/scale/src/util/math.ts new file mode 100644 index 000000000..44354f9d0 --- /dev/null +++ b/packages/scale/src/util/math.ts @@ -0,0 +1,61 @@ +import { each, isNil } from '@antv/util'; + +// 求以a为次幂,结果为b的基数,如 x^^a = b;求x +// 虽然数学上 b 不支持负数,但是这里需要支持 负数 +export function calBase(a: number, b: number) { + const e = Math.E; + let value; + if (b >= 0) { + value = Math.pow(e, Math.log(b) / a); // 使用换底公式求底 + } else { + value = Math.pow(e, Math.log(-b) / a) * -1; // 使用换底公式求底 + } + return value; +} + +export function log(a: number, b: number) { + if (a === 1) { + return 1; + } + return Math.log(b) / Math.log(a); +} + +export function getLogPositiveMin(values, base, max?: number) { + if (isNil(max)) { + max = Math.max.apply(null, values); + } + let positiveMin = max; + each(values, (value) => { + if (value > 0 && value < positiveMin) { + positiveMin = value; + } + }); + if (positiveMin === max) { + positiveMin = max / base; + } + if (positiveMin > 1) { + positiveMin = 1; + } + return positiveMin; +} + +function digitLength(num: number) { + // Get digit length of e + const eSplit = num.toString().split(/[eE]/); + const len = (eSplit[0].split('.')[1] || '').length - +(eSplit[1] || 0); + return len > 0 ? len : 0; +} + +/** + * 高精度加法,解决 0.1 + 0.2 !== 0.3 的经典问题 + * + * @param num1 加数 + * @param num2 被加数 + * @return {number} 返回值 + */ +export function precisionAdd(num1: number, num2: number) { + const num1Digits = digitLength(num1); + const num2Digits = digitLength(num2); + const baseNum = 10 ** Math.max(num1Digits, num2Digits); + return (num1 * baseNum + num2 * baseNum) / baseNum; +} diff --git a/packages/scale/src/util/pretty-number.ts b/packages/scale/src/util/pretty-number.ts new file mode 100644 index 000000000..2fb219bd1 --- /dev/null +++ b/packages/scale/src/util/pretty-number.ts @@ -0,0 +1,4 @@ +// 为了解决 js 运算的精度问题 +export function prettyNumber(n: number) { + return Math.abs(n) < 1e-15 ? n : parseFloat(n.toFixed(15)); +} diff --git a/packages/scale/src/util/pretty.ts b/packages/scale/src/util/pretty.ts new file mode 100644 index 000000000..2cba8c774 --- /dev/null +++ b/packages/scale/src/util/pretty.ts @@ -0,0 +1,60 @@ +import { prettyNumber } from './pretty-number'; + +export default function pretty(min: number, max: number, m: number = 5) { + if (min === max) { + return { + max, + min, + ticks: [min], + }; + } + + const n = m < 0 ? 0 : Math.round(m); + if (n === 0) return { max, min, ticks: [] }; + + /* + R pretty: + https://svn.r-project.org/R/trunk/src/appl/pretty.c + https://www.rdocumentation.org/packages/base/versions/3.5.2/topics/pretty + */ + const h = 1.5; // high.u.bias + const h5 = 0.5 + 1.5 * h; // u5.bias + // 反正我也不会调参,跳过所有判断步骤 + const d = max - min; + const c = d / n; + // 当d非常小的时候触发,但似乎没什么用 + // const min_n = Math.floor(n / 3); + // const shrink_sml = Math.pow(2, 5); + // if (Math.log10(d) < -2) { + // c = (_.max([ Math.abs(max), Math.abs(min) ]) * shrink_sml) / min_n; + // } + + const base = Math.pow(10, Math.floor(Math.log10(c))); + let unit = base; + if (2 * base - c < h * (c - unit)) { + unit = 2 * base; + if (5 * base - c < h5 * (c - unit)) { + unit = 5 * base; + if (10 * base - c < h * (c - unit)) { + unit = 10 * base; + } + } + } + const nu = Math.ceil(max / unit); + const ns = Math.floor(min / unit); + + const hi = Math.max(nu * unit, max); + const lo = Math.min(ns * unit, min); + + const size = Math.floor((hi - lo) / unit) + 1; + const ticks = new Array(size); + for (let i = 0; i < size; i++) { + ticks[i] = prettyNumber(lo + i * unit); + } + + return { + min: lo, + max: hi, + ticks, + }; +} diff --git a/packages/scale/src/util/strict-limit.ts b/packages/scale/src/util/strict-limit.ts new file mode 100644 index 000000000..68f882c8d --- /dev/null +++ b/packages/scale/src/util/strict-limit.ts @@ -0,0 +1,31 @@ +import { isNil } from '@antv/util'; +import { ScaleConfig } from '../types'; + +/** + * 按照给定的 minLimit/maxLimit/tickCount 均匀计算出刻度 ticks + * + * @param cfg Scale 配置项 + * @return ticks + */ +export default function strictLimit(cfg: ScaleConfig, defaultMin?: number, defaultMax?: number): number[] { + const { minLimit, maxLimit, min, max, tickCount = 5 } = cfg; + let tickMin = isNil(minLimit) ? (isNil(defaultMin) ? min : defaultMin) : minLimit; + let tickMax = isNil(maxLimit) ? (isNil(defaultMax) ? max : defaultMax) : maxLimit; + + if (tickMin > tickMax) { + [tickMax, tickMin] = [tickMin, tickMax]; + } + + if (tickCount <= 2) { + return [tickMin, tickMax]; + } + + const step = (tickMax - tickMin) / (tickCount - 1); + const ticks: number[] = []; + + for (let i = 0; i < tickCount; i++) { + ticks.push(tickMin + step * i); + } + + return ticks; +} diff --git a/packages/scale/src/util/time.ts b/packages/scale/src/util/time.ts new file mode 100644 index 000000000..1b840bd07 --- /dev/null +++ b/packages/scale/src/util/time.ts @@ -0,0 +1,71 @@ +import { isDate, isString, last } from '@antv/util'; +import fecha from 'fecha'; +import * as fecha1 from 'fecha'; + +import bisector from './bisector'; +const FORMAT_METHOD = 'format'; + +export function timeFormat(time, mask) { // 由于 fecha 包的 typescript 定义有问题,所以暂时兼容一下 + const method = fecha1[FORMAT_METHOD] || fecha[FORMAT_METHOD]; + return method(time, mask); +} +/** + * 转换成时间戳 + * @param value 时间值 + */ +export function toTimeStamp(value: any): number { + if (isString(value)) { + if (value.indexOf('T') > 0) { + value = new Date(value).getTime(); + } else { + // new Date('2010/01/10') 和 new Date('2010-01-10') 的差别在于: + // 如果仅有年月日时,前者是带有时区的: Fri Jan 10 2020 02:40:13 GMT+0800 (中国标准时间) + // 后者会格式化成 Sun Jan 10 2010 08:00:00 GMT+0800 (中国标准时间) + value = new Date(value.replace(/-/gi, '/')).getTime(); + } + } + if (isDate(value)) { + value = value.getTime(); + } + return value; +} + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const MONTH = DAY * 31; +const YEAR = DAY * 365; + +export { SECOND, MINUTE, HOUR, DAY, MONTH, YEAR }; +type Interval = [string, number]; // [defaultMomentFormat, interval] +const intervals: Interval[] = [ + ['HH:mm:ss', SECOND], + ['HH:mm:ss', SECOND * 10], + ['HH:mm:ss', SECOND * 30], + ['HH:mm', MINUTE], + ['HH:mm', MINUTE * 10], + ['HH:mm', MINUTE * 30], + ['HH', HOUR], + ['HH', HOUR * 6], + ['HH', HOUR * 12], + ['YYYY-MM-DD', DAY], + ['YYYY-MM-DD', DAY * 4], + ['YYYY-WW', DAY * 7], + ['YYYY-MM', MONTH], + ['YYYY-MM', MONTH * 4], + ['YYYY-MM', MONTH * 6], + ['YYYY', DAY * 380], // 借鉴echarts,保证每个周期累加时不会碰到恰巧不够的问题 +]; + +export function getTickInterval(min: number, max: number, tickCount: number): Interval { + const target = (max - min) / tickCount; + const idx = bisector((o: Interval) => o[1])(intervals, target) - 1; + let interval: Interval = intervals[idx]; + if (idx < 0) { + interval = intervals[0]; + } else if (idx >= intervals.length) { + interval = last(intervals); + } + return interval; +}