@antv/g2
Version:
the Grammar of Graphics in Javascript
567 lines (478 loc) • 16.6 kB
text/typescript
import { deepMix, each, find, get, head, isBoolean, last } from '@antv/util';
import { COMPONENT_MAX_VIEW_PERCENTAGE, COMPONENT_TYPE, DIRECTION, LAYER } from '../../constant';
import { Attribute, CategoryLegend, ContinuousLegend, GroupComponent, IGroup, Scale, Tick } from '../../dependents';
import { ComponentOption, LegendCfg, LegendOption, LooseObject, AllLegendsOptions, Padding } from '../../interface';
import { DEFAULT_ANIMATE_CFG } from '../../animate';
import Geometry from '../../geometry/base';
import { BBox } from '../../util/bbox';
import { directionToPosition } from '../../util/direction';
import { omit } from '../../util/helper';
import { getCustomLegendItems, getLegendItems, getLegendLayout, getLegendThemeCfg } from '../../util/legend';
import { getName } from '../../util/scale';
import View from '../view';
import { Controller } from './base';
type DoEach = (geometry: Geometry, attr: Attribute, scale: Scale) => void;
/**
* 从配置中获取单个字段的 legend 配置
* @param legends
* @param field
* @returns the option of one legend field
*/
function getLegendOption(legends: AllLegendsOptions, field: string) {
if (isBoolean(legends)) {
return legends === false ? false : {};
}
return get(legends, [field], legends);
}
function getDirection(legendOption: any): DIRECTION {
return get(legendOption, 'position', DIRECTION.BOTTOM);
}
/**
* @ignore
* legend Controller
*/
export default class Legend extends Controller<AllLegendsOptions> {
/** the draw group of axis */
private container: IGroup;
/** 用于多个 legend 布局的 bbox */
private layoutBBox: BBox;
constructor(view: View) {
super(view);
this.container = this.view.getLayer(LAYER.FORE).addGroup();
}
public get name(): string {
return 'legend';
}
public init() {}
/**
* render the legend component by legend options
*/
public render() {
// 和 update 逻辑保持一致
this.update();
}
/**
* layout legend
* 计算出 legend 的 direction 位置 x, y
*/
public layout() {
this.layoutBBox = this.view.viewBBox;
each(this.components, (co: ComponentOption) => {
const { component, direction } = co;
const layout = getLegendLayout(direction);
const maxWidthRatio = component.get('maxWidthRatio');
const maxHeightRatio = component.get('maxHeightRatio');
const maxSize = this.getCategoryLegendSizeCfg(layout, maxWidthRatio, maxHeightRatio);
const maxWidth = component.get('maxWidth');
const maxHeight = component.get('maxHeight');
// 先更新 maxSize,更新 layoutBBox,以便计算正确的 x y
component.update({
maxWidth: Math.min(maxSize.maxWidth, maxWidth || 0),
maxHeight: Math.min(maxSize.maxHeight, maxHeight || 0),
});
const padding = component.get('padding') as Padding;
const bboxObject = component.getLayoutBBox(); // 这里只需要他的 width、height 信息做位置调整
const bbox = new BBox(bboxObject.x, bboxObject.y, bboxObject.width, bboxObject.height).expand(padding);
const [x1, y1] = directionToPosition(this.view.viewBBox, bbox, direction);
const [x2, y2] = directionToPosition(this.layoutBBox, bbox, direction);
let x = 0;
let y = 0;
// 因为 legend x y 要和 coordinateBBox 对齐,所以要做一个简单的判断
if (direction.startsWith('top') || direction.startsWith('bottom')) {
x = x1;
y = y2;
} else {
x = x2;
y = y1;
}
// 更新位置
component.setLocation({ x: x + padding[3], y: y + padding[0] });
this.layoutBBox = this.layoutBBox.cut(bbox, direction);
});
}
/**
* legend 的更新逻辑
*/
public update() {
this.option = this.view.getOptions().legends;
// 已经处理过的 legend
const updated: Record<string, true> = {};
const eachLegend = (geometry: Geometry, attr: Attribute, scale: Scale) => {
const id = this.getId(scale.field);
const existCo = this.getComponentById(id);
// 存在则 update
if (existCo) {
let cfg;
const legendOption = getLegendOption(this.option, scale.field);
// if the legend option is not false, means legend should be created.
if (legendOption !== false) {
if (get(legendOption, 'custom')) {
cfg = this.getCategoryCfg(geometry, attr, scale, legendOption, true);
} else {
if (scale.isLinear) {
// linear field, create continuous legend
cfg = this.getContinuousCfg(geometry, attr, scale, legendOption);
} else if (scale.isCategory) {
// category field, create category legend
cfg = this.getCategoryCfg(geometry, attr, scale, legendOption);
}
}
}
// 如果 cfg 为空,则不在 updated 标记,那么会在后面逻辑中删除
if (cfg) {
// omit 掉一些属性,比如 container 等
omit(cfg, ['container']);
existCo.direction = getDirection(legendOption);
existCo.component.update(cfg);
// 标记为新的
updated[id] = true;
}
} else {
// 不存在则 create
const legend = this.createFieldLegend(geometry, attr, scale);
if (legend) {
(legend.component as GroupComponent).init();
this.components.push(legend);
// 标记为新的
updated[id] = true;
}
}
};
// 全局自定义图例
if (get(this.option, 'custom')) {
const id = 'global-custom';
const existCo = this.getComponentById(id);
if (existCo) {
const customCfg = this.getCategoryCfg(undefined, undefined, undefined, this.option, true);
omit(customCfg, ['container']);
existCo.component.update(customCfg);
updated[id] = true;
} else {
const component = this.createCustomLegend(undefined, undefined, undefined, this.option as LegendCfg);
if (component) {
component.init();
const layer = LAYER.FORE;
const direction = getDirection(this.option);
this.components.push({
id,
component,
layer,
direction,
type: COMPONENT_TYPE.LEGEND,
extra: undefined,
});
// 标记为更新
updated[id] = true;
}
}
} else {
// 遍历处理每一个创建逻辑
this.loopLegends(eachLegend);
}
// 处理完成之后,销毁删除的
// 不在处理中的
const components = [];
each(this.getComponents(), (co: ComponentOption) => {
if (updated[co.id]) {
components.push(co);
} else {
co.component.destroy();
}
});
// 更新当前已有的 components
this.components = components;
}
public clear() {
super.clear();
this.container.clear();
}
public destroy() {
super.destroy();
this.container.remove(true);
}
/**
* 递归获取所有的 Geometry
*/
private getGeometries(view: View): Geometry[] {
let geometries = view.geometries;
each(view.views, (v: View) => {
geometries = geometries.concat(this.getGeometries(v));
});
return geometries;
}
/**
* 遍历 Geometry,处理 legend 逻辑
* @param doEach 每个 loop 中的处理方法
*/
private loopLegends(doEach: DoEach) {
const isRootView = this.view.getRootView() === this.view;
// 非根 view,不处理 legend
if (!isRootView) {
return;
}
// 递归 view 中所有的 Geometry,进行创建 legend
const geometries = this.getGeometries(this.view);
const looped: Record<string, true> = {}; // 防止一个字段创建两个 legend
each(geometries, (geometry: Geometry) => {
const attributes = geometry.getGroupAttributes();
each(attributes, (attr: Attribute) => {
const scale = attr.getScale(attr.type);
// 如果在视觉通道上映射常量值,如 size(2) shape('circle') 不创建 legend
if (!scale || scale.type === 'identity' || looped[scale.field]) {
return;
}
doEach(geometry, attr, scale);
looped[scale.field] = true;
});
});
}
/**
* 创建一个 legend
* @param geometry
* @param attr
* @param scale
*/
private createFieldLegend(geometry: Geometry, attr: Attribute, scale: Scale): ComponentOption {
let component;
const legendOption = getLegendOption(this.option, scale.field);
const layer = LAYER.FORE;
const direction = getDirection(legendOption);
// if the legend option is not false, means legend should be created.
if (legendOption !== false) {
if (get(legendOption, 'custom')) {
component = this.createCustomLegend(geometry, attr, scale, legendOption);
} else {
if (scale.isLinear) {
// linear field, create continuous legend
component = this.createContinuousLegend(geometry, attr, scale, legendOption);
} else if (scale.isCategory) {
// category field, create category legend
component = this.createCategoryLegend(geometry, attr, scale, legendOption);
}
}
}
if (component) {
component.set('field', scale.field);
return {
id: this.getId(scale.field),
component,
layer,
direction,
type: COMPONENT_TYPE.LEGEND,
extra: { scale },
};
}
}
/**
* 自定义图例使用 category 图例去渲染
* @param geometry
* @param attr
* @param scale
* @param legendOption
*/
private createCustomLegend(geometry: Geometry, attr: Attribute, scale: Scale, legendOption: LegendCfg) {
// 直接使用 分类图例渲染
const cfg = this.getCategoryCfg(geometry, attr, scale, legendOption, true);
return new CategoryLegend(cfg);
}
/**
* 创建连续图例
* @param geometry
* @param attr
* @param scale
* @param legendOption
*/
private createContinuousLegend(geometry: Geometry, attr: Attribute, scale: Scale, legendOption: any) {
const cfg = this.getContinuousCfg(geometry, attr, scale, omit(legendOption, ['value']));
return new ContinuousLegend(cfg);
}
/**
* 创建分类图例
* @param geometry
* @param attr
* @param scale
* @param legendOption
*/
private createCategoryLegend(geometry: Geometry, attr: Attribute, scale: Scale, legendOption: any) {
const cfg = this.getCategoryCfg(geometry, attr, scale, legendOption);
return new CategoryLegend(cfg);
}
/**
* 获得连续图例的配置
* @param geometry
* @param attr
* @param scale
* @param legendOption
*/
private getContinuousCfg(geometry: Geometry, attr: Attribute, scale: Scale, legendOption: any) {
const ticks = scale.getTicks();
const containMin = find(ticks, (tick: Tick) => tick.value === 0);
const containMax = find(ticks, (tick: Tick) => tick.value === 1);
const items = ticks.map((tick: Tick) => {
const { value, tickValue } = tick;
const attrValue = attr.mapping(scale.invert(value)).join('');
return {
value: tickValue,
attrValue,
color: attrValue,
scaleValue: value,
};
});
if (!containMin) {
items.push({
value: scale.min,
attrValue: attr.mapping(scale.invert(0)).join(''),
color: attr.mapping(scale.invert(0)).join(''),
scaleValue: 0,
});
}
if (!containMax) {
items.push({
value: scale.max,
attrValue: attr.mapping(scale.invert(1)).join(''),
color: attr.mapping(scale.invert(1)).join(''),
scaleValue: 1,
});
}
// 排序
items.sort((a: any, b: any) => a.value - b.value);
// 跟 attr 相关的配置
// size color 区别的配置
const attrLegendCfg: LooseObject = {
min: head(items).value,
max: last(items).value,
colors: [],
rail: {
type: attr.type,
},
track: {},
};
if (attr.type === 'size') {
attrLegendCfg.track = {
style: {
// size 的选中前景色,对于 color,则直接使用 color 标识
// @ts-ignore
fill: attr.type === 'size' ? this.view.getTheme().defaultColor : undefined,
},
};
}
if (attr.type === 'color') {
attrLegendCfg.colors = items.map((item) => item.attrValue);
}
const container = this.container;
// if position is not set, use top as default
const direction = getDirection(legendOption);
const layout = getLegendLayout(direction);
let title = get(legendOption, 'title');
if (title) {
title = deepMix(
{
text: getName(scale),
},
title
);
}
// 基础配置,从当前数据中读到的配置
attrLegendCfg.container = container;
attrLegendCfg.layout = layout;
attrLegendCfg.title = title;
attrLegendCfg.animateOption = DEFAULT_ANIMATE_CFG;
// @ts-ignore
return this.mergeLegendCfg(attrLegendCfg, legendOption, 'continuous');
}
/**
* 获取分类图例的配置项
* @param geometry
* @param attr
* @param scale
* @param custom
* @param legendOption
*/
private getCategoryCfg(geometry: Geometry, attr: Attribute, scale: Scale, legendOption: any, custom?: boolean) {
const container = this.container;
// if position is not set, use top as default
const direction = get(legendOption, 'position', DIRECTION.BOTTOM);
const legendTheme = getLegendThemeCfg(this.view.getTheme(), direction);
// the default marker style
const themeMarker = get(legendTheme, ['marker']);
const userMarker = get(legendOption, 'marker');
const layout = getLegendLayout(direction);
const themePageNavigator = get(legendTheme, ['pageNavigator']);
const userPageNavigator = get(legendOption, 'pageNavigator');
const items = custom
? getCustomLegendItems(themeMarker, userMarker, legendOption.items)
: getLegendItems(this.view, geometry, attr, themeMarker, userMarker);
let title = get(legendOption, 'title');
if (title) {
title = deepMix(
{
text: scale ? getName(scale) : '',
},
title
);
}
const maxWidthRatio = get(legendOption, 'maxWidthRatio');
const maxHeightRatio = get(legendOption, 'maxHeightRatio');
const baseCfg: LooseObject = this.getCategoryLegendSizeCfg(layout, maxWidthRatio, maxHeightRatio);
baseCfg.container = container;
baseCfg.layout = layout;
baseCfg.items = items;
baseCfg.title = title;
baseCfg.animateOption = DEFAULT_ANIMATE_CFG;
baseCfg.pageNavigator = deepMix({}, themePageNavigator, userPageNavigator);
const categoryCfg = this.mergeLegendCfg(baseCfg, legendOption, direction);
if (categoryCfg.reversed) {
// 图例项需要逆序
categoryCfg.items.reverse();
}
const maxItemWidth = get(categoryCfg, 'maxItemWidth');
if (maxItemWidth && maxItemWidth <= 1) {
// 转换成像素值
categoryCfg.maxItemWidth = this.view.viewBBox.width * maxItemWidth;
}
return categoryCfg;
}
/**
* get legend config, use option > suggestion > theme
* @param baseCfg
* @param legendOption
* @param direction
*/
private mergeLegendCfg(baseCfg: object, legendOption: LegendOption, direction: DIRECTION) {
const position = direction.split('-')[0];
const themeObject = getLegendThemeCfg(this.view.getTheme(), position);
return deepMix({}, themeObject, baseCfg, legendOption);
}
/**
* 生成 id
* @param key
*/
private getId(key: string): string {
return `${this.name}-${key}`;
}
/**
* 根据 id 来获取组件
* @param id
*/
private getComponentById(id: string): ComponentOption {
return find(this.components, (co) => co.id === id);
}
private getCategoryLegendSizeCfg(
layout: 'horizontal' | 'vertical',
maxWidthRatio = COMPONENT_MAX_VIEW_PERCENTAGE,
maxHeightRatio = COMPONENT_MAX_VIEW_PERCENTAGE
) {
const { width: vw, height: vh } = this.view.viewBBox;
// 目前 legend 的布局是以 viewBBox 为参照
// const { width: cw, height: ch } = this.view.coordinateBBox;
return layout === 'vertical'
? {
maxWidth: vw * maxWidthRatio,
maxHeight: vh,
}
: {
maxWidth: vw,
maxHeight: vh * maxHeightRatio,
};
}
}