From c0fd91a2b8af30bca8f4ca0eb5e4cea95a4e9b89 Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 10 Mar 2020 11:13:08 +0800 Subject: [PATCH 01/16] =?UTF-8?q?refactor(label):=20=E6=8A=BD=E5=8F=96=20g?= =?UTF-8?q?etLabelId()=20=E6=96=B9=E6=B3=95=E6=96=B9=E4=BE=BF=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=E6=96=B0=20label=20=E6=8E=A5=E5=85=A5=E6=89=A9?= =?UTF-8?q?=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/geometry/label/base.ts | 39 +++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/geometry/label/base.ts b/src/geometry/label/base.ts index e9915e600e..cf451c3314 100644 --- a/src/geometry/label/base.ts +++ b/src/geometry/label/base.ts @@ -17,7 +17,7 @@ function avg(arr: number[]) { } /** - * Geometry Label 基类,用于解析 Geometry 下所有 label 的配置项信息 + * Geometry Label 基类,用于生成 Geometry 下所有 label 的配置项信息 */ export default class GeometryLabel { /** geometry 实例 */ @@ -67,7 +67,7 @@ export default class GeometryLabel { protected lineToLabel(item: LabelItem) {} /** - * 调整 labels + * 根据用户设置的 offsetX 和 offsetY 调整 label 的 x 和 y 坐标 * @param items * @returns */ @@ -87,7 +87,7 @@ export default class GeometryLabel { } /** - * 绘制文本线 + * 绘制 label 文本连接线 * @param items */ protected drawLines(items: LabelItem[]) { @@ -100,6 +100,7 @@ export default class GeometryLabel { // 内部文本不绘制 labelLine item.labelLine = null; } + if (item.labelLine) { this.lineToLabel(item); } @@ -258,6 +259,25 @@ export default class GeometryLabel { return align; } + protected getLabelId(mappingData: MappingDatum) { + const geometry = this.geometry; + const type = geometry.type; + const xScale = geometry.getXScale(); + const yScale = geometry.getYScale(); + const origin = mappingData[FIELD_ORIGIN]; // 原始数据 + + let labelId = geometry.getElementId(mappingData); + if (type === 'line' || type === 'area') { + // 折线图以及区域图,一条线会对应一组数据,即多个 labels,为了区分这些 labels,需要在 line id 的前提下加上 x 字段值 + labelId += ` ${origin[xScale.field]}`; + } else if (type === 'path') { + // path 路径图,无序,有可能存在相同 x 不同 y 的情况,需要通过 x y 来确定唯一 id + labelId += ` ${origin[xScale.field]}-${origin[yScale.field]}`; + } + + return labelId; + } + private getItems(mapppingArray: MappingDatum[]): LabelItem[] { const items = []; const labelCfgs = this.getLabelCfgs(mapppingArray); @@ -300,8 +320,6 @@ export default class GeometryLabel { const labelScales = fields.map((field: string) => { return scales[field]; }); - const xScale = geometry.getXScale(); - const yScale = geometry.getYScale(); const labelCfgs: LabelCfg[] = []; each(mapppingArray, (mappingData: MappingDatum, index: number) => { @@ -318,17 +336,8 @@ export default class GeometryLabel { } } - let labelId = geometry.getElementId(mappingData); - if (type === 'line' || type === 'area') { - // 折线图以及区域图,一条线会对应一组数据,即多个 labels,为了区分这些 labels,需要在 line id 的前提下加上 x 字段值 - labelId += ` ${origin[xScale.field]}`; - } else if (type === 'path') { - // path 路径图,无序,有可能存在相同 x 不同 y 的情况,需要通过 x y 来确定唯一 id - labelId += ` ${origin[xScale.field]}-${origin[yScale.field]}`; - } - let labelCfg = { - id: labelId, // 进行 ID 标记 + id: this.getLabelId(mappingData), // 进行 ID 标记 data: origin, // 存储原始数据 mappingData, // 存储映射后的数据, coordinate, // 坐标系 From 39d02120c272e355a8af72c3af3834da6cd705e9 Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 10 Mar 2020 14:32:45 +0800 Subject: [PATCH 02/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20interval=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=20label=20=E5=9C=A8=E8=BD=AC=E7=BD=AE?= =?UTF-8?q?=E7=9B=B4=E8=A7=92=E5=9D=90=E6=A0=87=E7=B3=BB=E4=B8=8B=20positi?= =?UTF-8?q?on=20=E8=AE=A1=E7=AE=97=E9=94=99=E8=AF=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/component/labels.ts | 3 ++- src/geometry/label/base.ts | 1 + src/geometry/label/interface.ts | 2 ++ src/geometry/label/interval.ts | 38 ++++++++++++++++----------------- src/theme/default.ts | 2 -- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/component/labels.ts b/src/component/labels.ts index 95cfb58418..f94b640da8 100644 --- a/src/component/labels.ts +++ b/src/component/labels.ts @@ -167,7 +167,7 @@ export default class Labels { if ((content.isGroup && content.isGroup()) || (content.isShape && content.isShape())) { // 如果 content 是 Group 或者 Shape,根据 textAlign 调整位置后,直接将其加入 labelGroup const { width, height } = content.getCanvasBBox(); - const textAlign = cfg.textAlign || 'left'; + const textAlign = get(cfg, 'textAlign', 'left'); let x = cfg.x; const y = cfg.y - (height / 2); @@ -187,6 +187,7 @@ export default class Labels { x: cfg.x, y: cfg.y, textAlign: cfg.textAlign, + textBaseline: get(cfg, 'textBaseline', 'middle'), text: cfg.content, ...cfg.style, }, diff --git a/src/geometry/label/base.ts b/src/geometry/label/base.ts index cf451c3314..f73eec23bf 100644 --- a/src/geometry/label/base.ts +++ b/src/geometry/label/base.ts @@ -219,6 +219,7 @@ export default class GeometryLabel { } if (labelCfg.position) { + // 如果 label 支持 position 属性 this.setLabelPosition(label, mappingData, index, labelCfg.position); } const offsetPoint = this.getLabelOffset(labelCfg, index, total); diff --git a/src/geometry/label/interface.ts b/src/geometry/label/interface.ts index 6fefbacea9..a995a3a58f 100644 --- a/src/geometry/label/interface.ts +++ b/src/geometry/label/interface.ts @@ -17,6 +17,7 @@ export interface LabelPointCfg { color?: string; content?: any; textAlign?: TextAlign; + textBaseline?: string; rotate?: number; angle?: number; r?: number; @@ -33,6 +34,7 @@ export interface LabelItem extends GeometryLabelCfg { color?: string; content?: any; textAlign?: TextAlign; + textBaseline?: string; rotate?: number; angle?: number; r?: number; diff --git a/src/geometry/label/interval.ts b/src/geometry/label/interval.ts index 58aaaf50c2..4807e37fe1 100644 --- a/src/geometry/label/interval.ts +++ b/src/geometry/label/interval.ts @@ -1,3 +1,5 @@ +import { get } from '@antv/util'; + import { MappingDatum, Point } from '../../interface'; import GeometryLabel from './base'; import { LabelPointCfg } from './interface'; @@ -12,41 +14,36 @@ export default class IntervalLabel extends GeometryLabel { const shapePoints = mappingData.points as Point[]; const point0 = coordinate.convert(shapePoints[0]); const point1 = coordinate.convert(shapePoints[2]); - const width = ((point0.x - point1.x) / 2) * (transposed ? -1 : 1); - const height = ((point0.y - point1.y) / 2) * (transposed ? -1 : 1); + const flag = transposed ? -1 : 1; + const width = ((point0.x - point1.x) / 2) * flag; + const height = ((point0.y - point1.y) / 2) * flag; switch (position) { case 'right': - if (transposed) { - labelPointCfg.x -= width; - labelPointCfg.y += height; - labelPointCfg.textAlign = labelPointCfg.textAlign || 'center'; - } else { + if (!transposed) { labelPointCfg.x -= width; labelPointCfg.y += height; - labelPointCfg.textAlign = labelPointCfg.textAlign || 'left'; } + labelPointCfg.textAlign = get(labelPointCfg, 'textAlign', 'left'); break; case 'left': if (transposed) { - labelPointCfg.x -= width; - labelPointCfg.y -= height; - labelPointCfg.textAlign = labelPointCfg.textAlign || 'center'; + labelPointCfg.x -= width * 2; } else { labelPointCfg.x += width; labelPointCfg.y += height; - labelPointCfg.textAlign = labelPointCfg.textAlign || 'right'; } + labelPointCfg.textAlign = get(labelPointCfg, 'textAlign', 'right'); break; case 'bottom': if (transposed) { - labelPointCfg.x -= width * 2; - labelPointCfg.textAlign = labelPointCfg.textAlign || 'left'; + labelPointCfg.x -= width; + labelPointCfg.y -= height; } else { labelPointCfg.y += height * 2; - labelPointCfg.textAlign = labelPointCfg.textAlign || 'center'; } - + labelPointCfg.textAlign = get(labelPointCfg, 'textAlign', 'center'); + labelPointCfg.textBaseline = get(labelPointCfg, 'textBaseline', 'top'); break; case 'middle': if (transposed) { @@ -54,14 +51,15 @@ export default class IntervalLabel extends GeometryLabel { } else { labelPointCfg.y += height; } - labelPointCfg.textAlign = labelPointCfg.textAlign || 'center'; + labelPointCfg.textAlign = get(labelPointCfg, 'textAlign', 'center'); break; case 'top': if (transposed) { - labelPointCfg.textAlign = labelPointCfg.textAlign || 'left'; - } else { - labelPointCfg.textAlign = labelPointCfg.textAlign || 'center'; + labelPointCfg.x -= width; + labelPointCfg.y += height; } + labelPointCfg.textAlign = get(labelPointCfg, 'textAlign', 'center'); + labelPointCfg.textBaseline = get(labelPointCfg, 'textBaseline', 'bottom'); break; default: break; diff --git a/src/theme/default.ts b/src/theme/default.ts index 836f6e5c63..cecbe9b0ea 100644 --- a/src/theme/default.ts +++ b/src/theme/default.ts @@ -1231,7 +1231,6 @@ export function getThemeByStylesheet(styleSheet: StyleSheet) { style: { fill: styleSheet.labelFillColor, fontSize: styleSheet.labelFontSize, - textBaseline: 'middle', fontFamily: styleSheet.fontFamily, stroke: styleSheet.labelBorderColor, lineWidth: styleSheet.labelBorder, @@ -1242,7 +1241,6 @@ export function getThemeByStylesheet(styleSheet: StyleSheet) { style: { fill: styleSheet.innerLabelFillColor, fontSize: styleSheet.innerLabelFontSize, - textBaseline: 'middle', fontFamily: styleSheet.fontFamily, stroke: styleSheet.innerLabelBorderColor, lineWidth: styleSheet.innerLabelBorder, From 803eb55e81be068cab085ad3cfa1d373bfbed95a Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 10 Mar 2020 14:34:36 +0800 Subject: [PATCH 03/16] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20interval=20l?= =?UTF-8?q?abel=20=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/geometry/label/interval-spec.ts | 322 +++++++++++++++------ 1 file changed, 232 insertions(+), 90 deletions(-) diff --git a/tests/unit/geometry/label/interval-spec.ts b/tests/unit/geometry/label/interval-spec.ts index c374801fe6..bcc149f00f 100644 --- a/tests/unit/geometry/label/interval-spec.ts +++ b/tests/unit/geometry/label/interval-spec.ts @@ -1,3 +1,5 @@ +import { flatten } from '@antv/util'; + import { getCoordinate } from '@antv/coord'; import Interval from '../../../../src/geometry/interval'; import IntervalLabel from '../../../../src/geometry/label/interval'; @@ -17,115 +19,255 @@ describe('interval labels', () => { }); const coord = new CartesianCoordinate({ - start: { - x: 80, - y: 168, - }, - end: { - x: 970, - y: 20, - }, + start: { x: 0, y: 200 }, + end: { x: 200, y: 0 }, }); - const points = [ - { - _origin: { country: 'Asia', year: '1750', value: 502, percent: 0.5169927909371782 }, - points: [ - { x: 0.03571428571428571, y: 0.48300720906282185 }, - { x: 0.03571428571428571, y: 1 }, - { x: 0.10714285714285714, y: 1 }, - { x: 0.10714285714285714, y: 0.48300720906282185 }, - ], - nextPoints: [ - { x: 0.03571428571428571, y: 0.3738414006179197 }, - { x: 0.03571428571428571, y: 0.48300720906282185 }, - { x: 0.10714285714285714, y: 0.48300720906282185 }, - { x: 0.10714285714285714, y: 0.3738414006179197 }, - ], - x: 143.57142857142856, - y: [219.04222451081358, 20], - color: '#FF6A84', - }, - { - _origin: { country: 'Asia', year: '1800', value: 635, percent: 0.5545851528384279 }, - points: [ - { x: 0.17857142857142855, y: 0.4454148471615721 }, - { x: 0.17857142857142855, y: 1 }, - { x: 0.25, y: 1 }, - { x: 0.25, y: 0.4454148471615721 }, - ], - x: 270.71428571428567, - y: [233.51528384279476, 20], - color: '#FF6A84', - }, - ]; + const transposedCoord = new CartesianCoordinate({ + start: { x: 50, y: 250 }, + end: { x: 250, y: 50 }, + }); + transposedCoord.transpose(); + const data = [ - { country: 'Asia', year: '1750', value: 502, percent: 0.5169927909371782 }, - { country: 'Asia', year: '1800', value: 635, percent: 0.5545851528384279 }, + { country: 'A', year: '1750', value: 502, percent: 0.5169927909371782 }, + { country: 'A', year: '1800', value: 635, percent: 0.5545851528384279 }, ]; const scaleDefs = { + year: { + range: [ 0.25, 0.75 ], + }, + country: { + range: [0.25, 0.75], + }, percent: { formatter: (val) => val.toFixed(4) * 100 + '%', }, + value: { + nice: true, + }, }; const scales = { year: createScale('year', data, scaleDefs), value: createScale('value', data, scaleDefs), percent: createScale('percent', data, scaleDefs), + country: createScale('country', data, scaleDefs), }; - const interval = new Interval({ - data, - scales, - container: canvas.addGroup(), - labelsContainer: canvas.addGroup(), - theme: Theme, - coordinate: coord, - scaleDefs, - }); - interval.position('year*value').label('percent', { - position: 'middle', - offset: 0, - }); - interval.init(); - - const gLabels = new IntervalLabel(interval); - - it('single label position middle', () => { - const items = gLabels.getLabelItems(points); - expect(items[0].x).toBe(143.57142857142856); - expect(items[0].y).toBe(58.257466529351184); - expect(items[0].textAlign).toBe('center'); - expect(items[1].x).toBe(270.71428571428567); - expect(items[1].y).toBe(61.03930131004366); - expect(items[1].textAlign).toBe('center'); - }); - it('single label position left', () => { - interval.label('percent', { - position: 'left', - offset: 0, + describe('cartesion', () => { + const interval = new Interval({ + data, + scales, + container: canvas.addGroup(), + labelsContainer: canvas.addGroup(), + theme: Theme, + coordinate: coord, + scaleDefs, + }); + interval + .position('year*value') + .size(30) + .label('percent', { + position: 'middle', + offset: 0, + }); + interval.init(); + interval.paint(); + + // 生成映射数据 + // @ts-ignore + const beforeMappingData = interval.beforeMappingData; + // @ts-ignore + const dataArray = interval.beforeMapping(beforeMappingData); + + let mappingArray = []; + for (const eachGroup of dataArray) { + // @ts-ignore + const mappingData = interval.mapping(eachGroup); + mappingArray.push(mappingData); + } + mappingArray = flatten(mappingArray); + + const gLabels = new IntervalLabel(interval); + const [ data1, data2 ] = mappingArray; + + it('single label position middle', () => { + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe(data1.x); + expect(item1.y).toBe((data1.y + coord.y.start) / 2); + expect(item1.textAlign).toBe('center'); + expect(item2.x).toBe(data2.x); + expect(item2.y).toBe((data2.y + coord.y.start) / 2); + expect(item2.textAlign).toBe('center'); + }); + + it('single label position left', () => { + interval.label('percent', { + position: 'left', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe(data1.x - 15); + expect(item1.y).toBe((data1.y + coord.y.start) / 2); + expect(item1.textAlign).toBe('right'); + expect(item2.x).toBe(data2.x - 15); + expect(item2.y).toBe((data2.y + coord.y.start) / 2); + expect(item2.textAlign).toBe('right'); + }); + + it('single label position right', () => { + interval.label('percent', { + position: 'right', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe(data1.x + 15); + expect(item1.y).toBe((data1.y + coord.y.start) / 2); + expect(item1.textAlign).toBe('left'); + expect(item2.x).toBe(data2.x + 15); + expect(item2.y).toBe((data2.y + coord.y.start) / 2); + expect(item2.textAlign).toBe('left'); + }); + + it('single label position top', () => { + interval.label('percent', { + position: 'top', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe(data1.x); + expect(item1.y).toBe(data1.y); + expect(item1.textAlign).toBe('center'); + expect(item1.textBaseline).toBe('bottom'); + expect(item2.x).toBe(data2.x); + expect(item2.y).toBe(data2.y); + expect(item2.textAlign).toBe('center'); + expect(item2.textBaseline).toBe('bottom'); + }); + + it('single label position bottom', () => { + interval.label('percent', { + position: 'bottom', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe(data1.x); + expect(item1.y).toBe(coord.y.start); + expect(item1.textAlign).toBe('center'); + expect(item1.textBaseline).toBe('top'); + expect(item2.x).toBe(data2.x); + expect(item2.y).toBe(coord.y.start); + expect(item2.textAlign).toBe('center'); + expect(item2.textBaseline).toBe('top'); }); - const items = gLabels.getLabelItems(points); - expect(items[0].x).toBe(111.78571428571428); - expect(items[0].y).toBe(58.257466529351184); - expect(items[0].textAlign).toBe('right'); - expect(items[1].x).toBe(238.9285714285714); - expect(items[1].y).toBe(61.03930131004366); - expect(items[1].textAlign).toBe('right'); }); - it('single label position right', () => { - interval.label('percent', { - position: 'right', - offset: 0, + describe('transposed coordinate', () => { + const interval = new Interval({ + data, + scales, + container: canvas.addGroup(), + labelsContainer: canvas.addGroup(), + theme: Theme, + coordinate: transposedCoord, + scaleDefs, + }); + interval + .position('country*value') + .color('year') + .adjust('stack') + .label('percent', { + position: 'middle', + offset: 0, + }); + interval.init(); + interval.paint(); + + // 生成映射数据 + // @ts-ignore + const beforeMappingData = interval.beforeMappingData; + // @ts-ignore + const dataArray = interval.beforeMapping(beforeMappingData); + + let mappingArray = []; + for (const eachGroup of dataArray) { + // @ts-ignore + const mappingData = interval.mapping(eachGroup); + mappingArray.push(mappingData); + } + mappingArray = flatten(mappingArray); + + const gLabels = new IntervalLabel(interval); + const [ data1, data2 ] = mappingArray; + it('single label position middle', () => { + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe((data1.x[0] + data1.x[1]) / 2); + expect(item1.y).toBe(200); + expect(item1.textAlign).toBe('center'); + expect(item2.x).toBe((data2.x[0] + data2.x[1]) / 2); + expect(item2.y).toBe(200); + expect(item2.textAlign).toBe('center'); + }); + + it('single label position left', () => { + interval.label('percent', { + position: 'left', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe(data1.x[0]); + expect(item1.y).toBe(200); + expect(item1.textAlign).toBe('right'); + expect(item2.x).toBe(data2.x[0]); + expect(item2.y).toBe(200); + expect(item2.textAlign).toBe('right'); + }); + + it('single label position right', () => { + interval.label('percent', { + position: 'right', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe(data1.x[1]); + expect(item1.y).toBe(200); + expect(item1.textAlign).toBe('left'); + expect(item2.x).toBe(data2.x[1]); + expect(item2.y).toBe(200); + expect(item2.textAlign).toBe('left'); + }); + + it('single label position top', () => { + interval.label('percent', { + position: 'top', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe((data1.x[0] + data1.x[1]) / 2); + expect(item1.y).toBe(150); + expect(item1.textAlign).toBe('center'); + expect(item1.textBaseline).toBe('bottom'); + expect(item2.x).toBe((data2.x[0] + data2.x[1]) / 2); + expect(item2.y).toBe(150); + expect(item2.textAlign).toBe('center'); + expect(item2.textBaseline).toBe('bottom'); + }); + + it('single label position bottom', () => { + interval.label('percent', { + position: 'bottom', + offset: 0, + }); + const [item1, item2] = gLabels.getLabelItems(mappingArray); + expect(item1.x).toBe((data1.x[0] + data1.x[1]) / 2); + expect(item1.y).toBe(250); + expect(item1.textAlign).toBe('center'); + expect(item1.textBaseline).toBe('top'); + expect(item2.x).toBe((data2.x[0] + data2.x[1]) / 2); + expect(item2.y).toBe(250); + expect(item2.textAlign).toBe('center'); + expect(item2.textBaseline).toBe('top'); }); - const items = gLabels.getLabelItems(points); - expect(items[0].x).toBe(175.35714285714283); - expect(items[0].y).toBe(58.257466529351184); - expect(items[0].textAlign).toBe('left'); - expect(items[1].x).toBe(302.49999999999994); - expect(items[1].y).toBe(61.03930131004366); - expect(items[1].textAlign).toBe('left'); }); }); From 72df6f0b79741709bb0d0eb4fb866a8de5e5f46c Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 10 Mar 2020 14:54:49 +0800 Subject: [PATCH 04/16] =?UTF-8?q?refactor:=20=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=20getPointAngle=20->=20getAngleByPoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chart/controller/annotation.ts | 6 ++--- src/chart/controller/tooltip.ts | 4 ++-- src/geometry/label/base.ts | 4 ++++ src/geometry/label/pie.ts | 6 ++--- src/geometry/label/polar.ts | 35 +++++++++--------------------- src/util/coordinate.ts | 2 +- tests/unit/util/coordinate-spec.ts | 8 +++---- 7 files changed, 27 insertions(+), 38 deletions(-) diff --git a/src/chart/controller/annotation.ts b/src/chart/controller/annotation.ts index 22dc325a39..e31355abe6 100644 --- a/src/chart/controller/annotation.ts +++ b/src/chart/controller/annotation.ts @@ -23,7 +23,7 @@ import { COMPONENT_TYPE, DIRECTION, LAYER, VIEW_LIFE_CIRCLE } from '../../consta import Geometry from '../../geometry/base'; import Element from '../../geometry/element'; -import { getDistanceToCenter, getPointAngle } from '../../util/coordinate'; +import { getAngleByPoint, getDistanceToCenter } from '../../util/coordinate'; import { omit } from '../../util/helper'; import View from '../view'; import { Controller } from './base'; @@ -528,8 +528,8 @@ export default class Annotation extends Controller { const { start, end } = option as ArcOption; const sp = this.parsePosition(start); const ep = this.parsePosition(end); - const startAngle = getPointAngle(coordinate, sp); - let endAngle = getPointAngle(coordinate, ep); + const startAngle = getAngleByPoint(coordinate, sp); + let endAngle = getAngleByPoint(coordinate, ep); if (startAngle > endAngle) { endAngle = Math.PI * 2 + endAngle; } diff --git a/src/chart/controller/tooltip.ts b/src/chart/controller/tooltip.ts index 75a1a7c6e9..53b7ebbee5 100644 --- a/src/chart/controller/tooltip.ts +++ b/src/chart/controller/tooltip.ts @@ -2,7 +2,7 @@ import { deepMix, each, find, flatten, get, isArray, isEqual, isFunction, mix } import { Crosshair, HtmlTooltip, IGroup } from '../../dependents'; import Geometry from '../../geometry/base'; import { MappingDatum, Point, TooltipOption } from '../../interface'; -import { getDistanceToCenter, getPointAngle, isPointInCoordinate } from '../../util/coordinate'; +import { getAngleByPoint, getDistanceToCenter, isPointInCoordinate } from '../../util/coordinate'; import { polarToCartesian } from '../../util/graphics'; import { findDataByPoint, getTooltipItems } from '../../util/tooltip'; import { Controller } from './base'; @@ -397,7 +397,7 @@ export default class Tooltip extends Controller { } } else { // 极坐标下 x 轴上的 crosshairs 表现为半径 - const angle = getPointAngle(coordinate, point); + const angle = getAngleByPoint(coordinate, point); const center = coordinate.getCenter(); // @ts-ignore const radius = coordinate.getRadius(); diff --git a/src/geometry/label/base.ts b/src/geometry/label/base.ts index f73eec23bf..7f27edf82c 100644 --- a/src/geometry/label/base.ts +++ b/src/geometry/label/base.ts @@ -260,6 +260,10 @@ export default class GeometryLabel { return align; } + /** + * 获取每一个 label 的唯一 id + * @param mappingData label 对应的图形的绘制数据 + */ protected getLabelId(mappingData: MappingDatum) { const geometry = this.geometry; const type = geometry.type; diff --git a/src/geometry/label/pie.ts b/src/geometry/label/pie.ts index 8772f18e0f..27f8d21f58 100644 --- a/src/geometry/label/pie.ts +++ b/src/geometry/label/pie.ts @@ -1,5 +1,5 @@ import { get, isArray, isObject } from '@antv/util'; -import { getPointAngle } from '../../util/coordinate'; +import { getAngleByPoint } from '../../util/coordinate'; import Geometry from '../base'; import { LabelItem } from './interface'; import PolarLabel from './polar'; @@ -195,11 +195,11 @@ export default class PieLabel extends PolarLabel { y: point.y[1], }; let angle; - const startAngle = getPointAngle(coordinate, startPoint); + const startAngle = getAngleByPoint(coordinate, startPoint); if (point.points && point.points[0].y === point.points[1].y) { angle = startAngle; } else { - let endAngle = getPointAngle(coordinate, endPoint); + let endAngle = getAngleByPoint(coordinate, endPoint); if (startAngle >= endAngle) { // 100% pie slice endAngle = endAngle + Math.PI * 2; diff --git a/src/geometry/label/polar.ts b/src/geometry/label/polar.ts index eab6f0425c..6782b1a138 100644 --- a/src/geometry/label/polar.ts +++ b/src/geometry/label/polar.ts @@ -1,7 +1,7 @@ import { each, isArray } from '@antv/util'; import { MappingDatum, Point } from '../../interface'; import { getDistanceToCenter } from '../../util/coordinate'; -import { getPointAngle } from '../../util/coordinate'; +import { getAngleByPoint } from '../../util/coordinate'; import GeometryLabel from './base'; import { LabelCfg, LabelItem, LabelPointCfg } from './interface'; @@ -15,11 +15,7 @@ export default class PolarLabel extends GeometryLabel { const coordinate = this.coordinate; let align; if (point.labelEmit) { - if (point.angle <= Math.PI / 2 && point.angle > -Math.PI / 2) { - align = 'left'; - } else { - align = 'right'; - } + align = (point.angle <= Math.PI / 2 && point.angle > -Math.PI / 2) ? 'left' : 'right'; } else if (!coordinate.isTransposed) { align = 'center'; } else { @@ -28,17 +24,9 @@ export default class PolarLabel extends GeometryLabel { if (Math.abs(point.x - center.x) < 1) { align = 'center'; } else if (point.angle > Math.PI || point.angle <= 0) { - if (offset > 0) { - align = 'left'; - } else { - align = 'right'; - } + align = offset > 0 ? 'left' : 'right'; } else { - if (offset > 0) { - align = 'right'; - } else { - align = 'left'; - } + align = offset > 0 ? 'right' : 'left'; } } return align; @@ -83,25 +71,22 @@ export default class PolarLabel extends GeometryLabel { } protected getArcPoint(mappingData: MappingDatum, index: number = 0): Point { - let arcPoint; if (!isArray(mappingData.x) && !isArray(mappingData.y)) { - arcPoint = { + return { x: mappingData.x, y: mappingData.y, }; - } else { - arcPoint = { - x: isArray(mappingData.x) ? mappingData.x[index] : mappingData.x, - y: isArray(mappingData.y) ? mappingData.y[index] : mappingData.y, - }; } - return arcPoint; + return { + x: isArray(mappingData.x) ? mappingData.x[index] : mappingData.x, + y: isArray(mappingData.y) ? mappingData.y[index] : mappingData.y, + }; } // 获取点所在的角度 protected getPointAngle(point: Point): number { - return getPointAngle(this.coordinate, point); + return getAngleByPoint(this.coordinate, point); } protected getCirclePoint(angle: number, offset: number, point: Point, isLabelEmit: boolean) { diff --git a/src/util/coordinate.ts b/src/util/coordinate.ts index 29eb8f21bf..10d5e58311 100644 --- a/src/util/coordinate.ts +++ b/src/util/coordinate.ts @@ -76,7 +76,7 @@ export function isPointInCoordinate(coordinate: Coordinate, point: Point) { * @ignore * 获取点到圆心的连线与水平方向的夹角 */ -export function getPointAngle(coordinate: Coordinate, point: Point): number { +export function getAngleByPoint(coordinate: Coordinate, point: Point): number { const center = coordinate.getCenter(); return Math.atan2(point.y - center.y, point.x - center.x); } diff --git a/tests/unit/util/coordinate-spec.ts b/tests/unit/util/coordinate-spec.ts index 5fcaced904..91c440bc69 100644 --- a/tests/unit/util/coordinate-spec.ts +++ b/tests/unit/util/coordinate-spec.ts @@ -1,5 +1,5 @@ import { getCoordinate } from '@antv/coord'; -import { getDistanceToCenter, getPointAngle, getXDimensionLength, isFullCircle } from '../../../src/util/coordinate'; +import { getAngleByPoint, getDistanceToCenter, getXDimensionLength, isFullCircle } from '../../../src/util/coordinate'; const Polar = getCoordinate('polar'); const Cartesian = getCoordinate('rect'); @@ -70,7 +70,7 @@ describe('CoordinateUtil', () => { ).toBe(100); }); - it('getPointAngle()', () => { + it('getAngleByPoint()', () => { const coord = new Polar({ start: { x: 0, @@ -82,13 +82,13 @@ describe('CoordinateUtil', () => { }, }); expect( - getPointAngle(coord, { + getAngleByPoint(coord, { x: 100, y: 100, }) ).toBe(0); expect( - getPointAngle(coord, { + getAngleByPoint(coord, { x: 0, y: 100, }) From a114e9f058e4e6604479a03022eec0a22fd0992b Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 10 Mar 2020 15:28:56 +0800 Subject: [PATCH 05/16] =?UTF-8?q?refactor:=20=E4=B8=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=B7=A5=E5=85=B7=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/geometry/label/pie.ts | 16 +++++----------- src/geometry/label/polar.ts | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/geometry/label/pie.ts b/src/geometry/label/pie.ts index 27f8d21f58..bb85cd1068 100644 --- a/src/geometry/label/pie.ts +++ b/src/geometry/label/pie.ts @@ -1,5 +1,6 @@ import { get, isArray, isObject } from '@antv/util'; import { getAngleByPoint } from '../../util/coordinate'; +import { polarToCartesian } from '../../util/graphics'; import Geometry from '../base'; import { LabelItem } from './interface'; import PolarLabel from './polar'; @@ -7,13 +8,6 @@ import PolarLabel from './polar'; /** label text和line距离 4px */ const MARGIN = 4; -function getEndPoint(center, angle, r) { - return { - x: center.x + r * Math.cos(angle), - y: center.y + r * Math.sin(angle), - }; -} - function antiCollision(labels, lineHeight, plotRange, center, isRight) { // adjust y position of labels to avoid overlapping let overlapping = true; @@ -132,8 +126,8 @@ export default class PieLabel extends PolarLabel { const angle = label.angle; const center = coordinate.getCenter(); // 贴近圆周 - const start = getEndPoint(center, angle, r); - const inner = getEndPoint(center, angle, r + distance / 2); + const start = polarToCartesian(center.x, center.y, r, angle); + const inner = polarToCartesian(center.x, center.y, r + distance / 2, angle); const end = { x: label.x - Math.cos(angle) * MARGIN, y: label.y - Math.sin(angle) * MARGIN, @@ -209,13 +203,13 @@ export default class PieLabel extends PolarLabel { return angle; } - public getCirclePoint(angle, offset, p?) { + protected getCirclePoint(angle, offset, p?) { const coordinate = this.coordinate; const center = coordinate.getCenter(); // @ts-ignore const r = coordinate.getRadius() + offset; return { - ...getEndPoint(center, angle, r), + ...polarToCartesian(center.x, center.y, r, angle), angle, r, }; diff --git a/src/geometry/label/polar.ts b/src/geometry/label/polar.ts index 6782b1a138..e67fe9cd80 100644 --- a/src/geometry/label/polar.ts +++ b/src/geometry/label/polar.ts @@ -11,6 +11,11 @@ const HALF_PI = Math.PI / 2; * 极坐标下的图形 label */ export default class PolarLabel extends GeometryLabel { + /** + * @override + * 获取文本的对齐方式 + * @param point + */ protected getLabelAlign(point: LabelItem) { const coordinate = this.coordinate; let align; @@ -32,6 +37,13 @@ export default class PolarLabel extends GeometryLabel { return align; } + /** + * @override + * 获取 label 的位置 + * @param labelCfg + * @param mappingData + * @param index + */ protected getLabelPoint(labelCfg: LabelCfg, mappingData: MappingDatum, index: number): LabelPointCfg { let factor = 1; let arcPoint; @@ -70,6 +82,9 @@ export default class PolarLabel extends GeometryLabel { return labelPositionCfg; } + /** + * 获取圆弧的位置 + */ protected getArcPoint(mappingData: MappingDatum, index: number = 0): Point { if (!isArray(mappingData.x) && !isArray(mappingData.y)) { return { @@ -84,11 +99,21 @@ export default class PolarLabel extends GeometryLabel { }; } - // 获取点所在的角度 + /** + * 计算坐标线点在极坐标系下角度 + * @param point + */ protected getPointAngle(point: Point): number { return getAngleByPoint(this.coordinate, point); } + /** + * 获取坐标点与圆心形成的圆的位置信息 + * @param angle + * @param offset + * @param point + * @param isLabelEmit + */ protected getCirclePoint(angle: number, offset: number, point: Point, isLabelEmit: boolean) { const coordinate = this.coordinate; const center = coordinate.getCenter(); @@ -115,7 +140,12 @@ export default class PolarLabel extends GeometryLabel { }; } - // angle 为弧度 + /** + * 获取 label 的旋转角度 + * @param angle + * @param offset + * @param isLabelEmit + */ protected getLabelRotate(angle: number, offset: number, isLabelEmit: boolean) { let rotate = angle + HALF_PI; if (isLabelEmit) { From 219126ed063705f2361484bc99f4002bea0fc2e2 Mon Sep 17 00:00:00 2001 From: simaQ Date: Wed, 11 Mar 2020 13:58:56 +0800 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20export=20=E6=89=80=E6=9C=89?= =?UTF-8?q?=E7=9A=84=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89=EF=BC=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=AF=B9=E5=BA=94=E7=9A=84=20API=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/interface.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/interface.ts b/src/interface.ts index f799a256e6..98e4a646a4 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -292,7 +292,7 @@ export interface StateOption { }; /** interval label 的位置 */ -type IntervalGeometryLabelPosition = 'top' | 'bottom' | 'middle' | 'left' | 'right'; +export type IntervalGeometryLabelPosition = 'top' | 'bottom' | 'middle' | 'left' | 'right'; /** G2 提供的 adjust 类型 */ export type AdjustType = 'stack' | 'jitter' | 'dodge' | 'symmetric'; /** geometry.color() 图形属性回调函数定义 */ @@ -398,7 +398,7 @@ export type ShapeMarkerSymbol = (x: number, y: number, r: number) => PathCommand // ============================ Annotation 类型定义 ============================ /** Annotation position 回调函数 */ -type AnnotationPositionCallback = ( +export type AnnotationPositionCallback = ( xScales: Scale[] | Record, yScales: Scale[] | Record ) => [number, number]; @@ -503,7 +503,7 @@ export interface RegionFilterOption extends RegionPositionBaseOption { // ============================ Chart && View 上的类型定义 ============================ /** Tooltip 内容框的 css 样式定义 */ -interface TooltipDomStyles { +export interface TooltipDomStyles { 'g2-tooltip'?: LooseObject; 'g2-tooltip-title'?: LooseObject; 'g2-tooltip-list'?: LooseObject; @@ -514,7 +514,7 @@ interface TooltipDomStyles { } /** 目前组件动画允许的参数配置 */ -interface ComponentAnimateCfg { +export interface ComponentAnimateCfg { /** 动画执行时间 */ readonly duration?: number; /** 动画缓动函数 */ @@ -523,7 +523,7 @@ interface ComponentAnimateCfg { readonly delay?: number; } /** 组件各个动画类型配置 */ -interface ComponentAnimateOption { +export interface ComponentAnimateOption { /** 初入场动画配置 */ appear?: ComponentAnimateCfg; /** 更新动画配置 */ @@ -759,7 +759,7 @@ export interface ComponentOption { } /** Legend marker 的配置结构 */ -interface MarkerCfg extends LegendMarkerCfg { +export interface MarkerCfg extends LegendMarkerCfg { /** 配置图例 marker 的 symbol 形状。 */ symbol?: Marker | MarkerCallback; } @@ -902,7 +902,7 @@ export interface LegendCfg { /** * 配置属性详见 {@link https://github.com/antvis/component/blob/81890719a431b3f9088e0c31c4d5d382ef0089df/src/types.ts#L1154|CrosshairTextCfg},Tooltip Crosshairs 的文本数据结构 */ -interface TooltipCrosshairsText extends CrosshairTextCfg { +export interface TooltipCrosshairsText extends CrosshairTextCfg { /** crosshairs 文本内容 */ content?: string; } @@ -915,7 +915,7 @@ interface TooltipCrosshairsText extends CrosshairTextCfg { * @param currentPoint 对应当前坐标点 * @returns 返回当前 crosshairs 对应的辅助线文本配置 */ -type TooltipCrosshairsTextCallback = (type: string, defaultContent: any, items: any[], currentPoint: Point) => TooltipCrosshairsText; +export type TooltipCrosshairsTextCallback = (type: string, defaultContent: any, items: any[], currentPoint: Point) => TooltipCrosshairsText; /** Tooltip crosshairs 配置结构 */ export interface TooltipCrosshairs { /** @@ -1116,7 +1116,7 @@ export interface Options { } /** 支持的 Marker 类型 */ -type Marker = +export type Marker = | 'circle' | 'square' | 'diamond' @@ -1130,7 +1130,7 @@ type Marker = | 'hyphen' | 'line'; /** 自定义 Marker 的回调函数定义 */ -type MarkerCallback = (x: number, y: number, r: number) => PathCommand; +export type MarkerCallback = (x: number, y: number, r: number) => PathCommand; /** chart.tooltip() 参数类型 */ export type TooltipOption = TooltipCfg | boolean; /* 筛选器函数类型定义 */ @@ -1152,10 +1152,10 @@ export type ScaleType = 'quantize' | 'quantile'; -type CoordinateRotate = ['rotate', number]; -type CoordinateReflect = ['reflect', 'x' | 'y']; -type CoordinateScale = ['scale', number, number]; -type CoordinateTranspose = ['transpose']; +export type CoordinateRotate = ['rotate', number]; +export type CoordinateReflect = ['reflect', 'x' | 'y']; +export type CoordinateScale = ['scale', number, number]; +export type CoordinateTranspose = ['transpose']; /** 坐标系支持的 action 配置 */ export type CoordinateActions = CoordinateRotate | CoordinateReflect | CoordinateScale | CoordinateTranspose; From 4537b965e61f9fd2774b76647a7d9a845c8d97fa Mon Sep 17 00:00:00 2001 From: simaQ Date: Wed, 11 Mar 2020 22:47:04 +0800 Subject: [PATCH 07/16] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=20Geometry?= =?UTF-8?q?Label=E3=80=82=E5=B0=86=20labels=20=E7=BB=84=E4=BB=B6=E7=9A=84?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=B8=B2=E6=9F=93=E7=A7=BB=E5=85=A5=20Geomtr?= =?UTF-8?q?yLabel=20=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/geometry/base.ts | 97 +++++++++------------- src/geometry/label/base.ts | 100 ++++++++++++++++++++--- src/geometry/label/interval.ts | 3 +- src/geometry/label/pie.ts | 16 ++-- src/geometry/label/polar.ts | 8 +- tests/unit/geometry/label/labels-spec.ts | 8 +- 6 files changed, 147 insertions(+), 85 deletions(-) diff --git a/src/geometry/base.ts b/src/geometry/base.ts index 6da1611e7f..ffabc7091d 100644 --- a/src/geometry/base.ts +++ b/src/geometry/base.ts @@ -55,6 +55,7 @@ import { getShapeFactory } from './shape/base'; import { group } from './util/group-data'; import { isModelChange } from './util/is-model-change'; import { parseFields } from './util/parse-fields'; +import GeometryLabel from './label/base'; /** @ignore */ interface AttributeInstanceCfg { @@ -192,8 +193,6 @@ export default class Geometry extends Base { protected lastElementsMap: Record = {}; /** 是否生成多个点来绘制图形。 */ protected generatePoints: boolean = false; - /** 虚拟 Group,用于图形更新 */ - protected offscreenGroup: IGroup; /** 存储发生图形属性映射前的数据 */ protected beforeMappingData: Data[] = null; /** 存储每个 shape 的默认 size,用于 Interval、Schema 几何标记 */ @@ -201,8 +200,10 @@ export default class Geometry extends Base { private adjusts: Record = {}; private lastAttributeOption; - private labelsRenderer: Labels; private idFields: string[] = []; + private geometryLabel: GeometryLabel; + /** 虚拟 Group,用于图形更新 */ + private offscreenGroup: IGroup; /** * 创建 Geometry 实例。 @@ -875,13 +876,13 @@ export default class Geometry extends Base { * @override */ public clear() { - const { container, labelsRenderer } = this; + const { container, geometryLabel } = this; if (container) { container.clear(); } - if (labelsRenderer) { - labelsRenderer.clear(); + if (geometryLabel) { + geometryLabel.clear(); } // 属性恢复至出厂状态 @@ -910,9 +911,9 @@ export default class Geometry extends Base { this.offscreenGroup = null; } - if (this.labelsRenderer) { - this.labelsRenderer.destroy(); - this.labelsRenderer = null; + if (this.geometryLabel) { + this.geometryLabel.destroy(); + this.geometryLabel = null; } super.destroy(); } @@ -1176,6 +1177,18 @@ export default class Geometry extends Base { return this.elements.map((element: Element) => element.shape); } + /** + * 获取虚拟 Group。 + * @returns + */ + public getOffscreenGroup() { + if (!this.offscreenGroup) { + const GroupCtor = this.container.getGroupBase(); // 获取分组的构造函数 + this.offscreenGroup = new GroupCtor({}); + } + return this.offscreenGroup; + } + /** * 调整度量范围。主要针对发生层叠以及一些特殊需求的 Geometry,比如 Interval 下的柱状图 Y 轴默认从 0 开始。 */ @@ -1318,34 +1331,25 @@ export default class Geometry extends Base { return elements; } - /** - * 获取虚拟 Group。 - * @returns - */ - protected getOffscreenGroup() { - if (!this.offscreenGroup) { - const GroupCtor = this.container.getGroupBase(); // 获取分组的构造函数 - this.offscreenGroup = new GroupCtor({}); - } - return this.offscreenGroup; - } - /** * 获取渲染的 label 类型。 */ protected getLabelType(): string { const { labelOption, coordinate, type } = this; const coordinateType = coordinate.type; - let labelType = get(labelOption, ['cfg', 'type']) || 'base'; - if (labelType === 'base') { + let labelType = get(labelOption, ['cfg', 'type']); + if (!labelType) { + // 用户未定义,则进行默认的逻辑 if (coordinateType === 'polar') { - // 极坐标文本 + // 极坐标下使用通用的极坐标文本 labelType = 'polar'; } else if (coordinateType === 'theta') { - // 饼图文本 + // theta 坐标系下使用饼图文本 labelType = 'pie'; } else if (type === 'interval' || type === 'polygon') { labelType = 'interval'; + } else { + labelType = 'base'; } } @@ -1803,40 +1807,19 @@ export default class Geometry extends Base { } private renderLabels(mappingArray: MappingDatum[], isUpdate: boolean = false) { - const { labelOption, animateOption, coordinate } = this; - const labelType = this.getLabelType(); - - const GeometryLabelsCtor = getGeometryLabel(labelType); - const geometryLabels = new GeometryLabelsCtor(this); - const labelItems = geometryLabels.getLabelItems(mappingArray); - - let labelsRenderer = this.labelsRenderer; - if (!labelsRenderer) { - labelsRenderer = new Labels({ - container: this.labelsContainer, - layout: get(labelOption, ['cfg', 'layout']), - }); - this.labelsRenderer = labelsRenderer; + let geometryLabel = this.geometryLabel; + + if (!geometryLabel) { + // 初次创建 + const labelType = this.getLabelType(); + const GeometryLabelsCtor = getGeometryLabel(labelType); + geometryLabel = new GeometryLabelsCtor(this); + this.geometryLabel = geometryLabel; } - labelsRenderer.region = this.canvasRegion; - - const shapes = {}; - each(this.elementsMap, (element: Element, id: string) => { - shapes[id] = element.shape; - }); - // 因为有可能 shape 还在进行动画,导致 shape.getBBox() 获取到的值不是最终态,所以需要从 offscreenGroup 获取 - each(this.offscreenGroup.getChildren(), (child) => { - const id = this.getElementId(child.get('origin').mappingData); - shapes[id] = child; - }); - - // 设置动画配置,如果 geometry 的动画关闭了,那么 label 的动画也会关闭 - labelsRenderer.animate = animateOption ? getDefaultAnimateCfg('label', coordinate) : false; - - // 渲染文本 - labelsRenderer.render(labelItems, shapes, isUpdate); + geometryLabel.render(mappingArray, isUpdate); - const labelsMap = this.labelsRenderer.shapesMap; + // 将 label 同 element 进行关联 + const labelsMap = geometryLabel.labelsRenderer.shapesMap; each(this.elementsMap, (element: Element, id) => { const labels = filterLabelsById(id, labelsMap); // element 实例同 label 进行绑定 element.labelShape = labels; diff --git a/src/geometry/label/base.ts b/src/geometry/label/base.ts index 7f27edf82c..46f76684cd 100644 --- a/src/geometry/label/base.ts +++ b/src/geometry/label/base.ts @@ -1,10 +1,16 @@ import { deepMix, each, get, isArray, isFunction, isNil, isNumber, isUndefined } from '@antv/util'; + import { FIELD_ORIGIN } from '../../constant'; import { Coordinate, Scale } from '../../dependents'; import { Datum, LabelOption, LooseObject, MappingDatum, Point } from '../../interface'; +import { LabelCfg, LabelItem, LabelPointCfg, TextAlign } from './interface'; + +import { getDefaultAnimateCfg } from '../../animate'; import { getPolygonCentroid } from '../../util/graphics'; + +import Labels from '../../component/labels'; import Geometry from '../base'; -import { LabelCfg, LabelItem, LabelPointCfg, TextAlign } from './interface'; +import Element from '../element'; export type GeometryLabelConstructor = new (cfg: any) => GeometryLabel; @@ -22,16 +28,42 @@ function avg(arr: number[]) { export default class GeometryLabel { /** geometry 实例 */ public readonly geometry: Geometry; - /** 坐标系实例 */ - protected coordinate: Coordinate; - /** 默认的 label 配置 */ - protected defaultLabelCfg: LooseObject; + public labelsRenderer: Labels; constructor(geometry: Geometry) { this.geometry = geometry; + } + + public render(mapppingArray: MappingDatum[], isUpdate: boolean) { + let labelItems = this.getItems(mapppingArray); + labelItems = this.adjustItems(labelItems); + + this.drawLines(labelItems); + + const labelsRenderer = this.getLabelsRenderer(); + const shapes = this.getGeometryShapes(); + // 渲染文本 + labelsRenderer.render(labelItems, shapes, isUpdate); + } + + public clear() { + const labelsRenderer = this.labelsRenderer; + if (labelsRenderer) { + labelsRenderer.clear(); + } + } + + public destroy() { + const labelsRenderer = this.labelsRenderer; + if (labelsRenderer) { + labelsRenderer.destroy(); + } + this.labelsRenderer = null; + } - this.coordinate = geometry.coordinate; - this.defaultLabelCfg = get(geometry.theme, 'labels', {}); // 默认样式 + // geometry 更新之后,对应的 Coordinate 也会更新,为了获取到最新鲜的 Coordinate,故使用方法获取 + public getCoordinate() { + return this.geometry.coordinate; } /** @@ -46,6 +78,13 @@ export default class GeometryLabel { return items; } + /** + * 获取 label 的默认配置 + */ + protected getDefaultLabelCfg() { + return get(this.geometry.theme, 'labels', {}); + } + /** * 设置 label 位置 * @param labelPointCfg @@ -113,7 +152,7 @@ export default class GeometryLabel { * @returns */ protected getDefaultOffset(offset: number) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const vector = this.getOffsetVector(offset); return coordinate.isTransposed ? vector[0] : vector[1]; } @@ -127,7 +166,7 @@ export default class GeometryLabel { */ protected getLabelOffset(labelCfg: LabelCfg, index: number, total: number) { const offset = this.getDefaultOffset(labelCfg.offset); - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const transposed = coordinate.isTransposed; const dim = transposed ? 'x' : 'y'; const factor = transposed ? 1 : -1; // y 方向上越大,像素的坐标越小,所以transposed时将系数变成 @@ -152,7 +191,7 @@ export default class GeometryLabel { * @returns label point */ protected getLabelPoint(labelCfg: LabelCfg, mappingData: MappingDatum, index: number): LabelPointCfg { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const total = labelCfg.content.length; function getDimValue(value, idx) { @@ -239,7 +278,7 @@ export default class GeometryLabel { */ protected getLabelAlign(item: LabelItem, index: number, total: number): TextAlign { let align: TextAlign = 'center'; - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); if (coordinate.isTransposed) { const offset = this.getDefaultOffset(item.offset); if (offset < 0) { @@ -283,6 +322,26 @@ export default class GeometryLabel { return labelId; } + // 获取 labels 组件 + private getLabelsRenderer() { + const { labelsContainer, labelOption, canvasRegion, animateOption } = this.geometry; + const coordinate = this.geometry.coordinate; + + let labelsRenderer = this.labelsRenderer; + if (!labelsRenderer) { + labelsRenderer = new Labels({ + container: labelsContainer, + layout: get(labelOption, ['cfg', 'layout']), + }); + this.labelsRenderer = labelsRenderer; + } + labelsRenderer.region = canvasRegion; + // 设置动画配置,如果 geometry 的动画关闭了,那么 label 的动画也会关闭 + labelsRenderer.animate = animateOption ? getDefaultAnimateCfg('label', coordinate) : false; + + return labelsRenderer; + } + private getItems(mapppingArray: MappingDatum[]): LabelItem[] { const items = []; const labelCfgs = this.getLabelCfgs(mapppingArray); @@ -319,7 +378,7 @@ export default class GeometryLabel { private getLabelCfgs(mapppingArray: MappingDatum[]): LabelCfg[] { const geometry = this.geometry; - const defaultLabelCfg = this.defaultLabelCfg; + const defaultLabelCfg = this.getDefaultLabelCfg(); const { type, theme, labelOption, scales, coordinate } = geometry; const { fields, callback, cfg } = labelOption as LabelOption; const labelScales = fields.map((field: string) => { @@ -397,8 +456,23 @@ export default class GeometryLabel { } private getOffsetVector(offset = 0) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); // 如果 x,y 翻转,则偏移 x,否则偏移 y return coordinate.isTransposed ? coordinate.applyMatrix(offset, 0) : coordinate.applyMatrix(0, offset); } + + private getGeometryShapes() { + const geometry = this.geometry; + const shapes = {}; + each(geometry.elementsMap, (element: Element, id: string) => { + shapes[id] = element.shape; + }); + // 因为有可能 shape 还在进行动画,导致 shape.getBBox() 获取到的值不是最终态,所以需要从 offscreenGroup 获取 + each(geometry.getOffscreenGroup().getChildren(), (child) => { + const id = geometry.getElementId(child.get('origin').mappingData); + shapes[id] = child; + }); + + return shapes; + } } diff --git a/src/geometry/label/interval.ts b/src/geometry/label/interval.ts index 4807e37fe1..33d364c774 100644 --- a/src/geometry/label/interval.ts +++ b/src/geometry/label/interval.ts @@ -8,8 +8,9 @@ import { LabelPointCfg } from './interface'; * 柱状图 label */ export default class IntervalLabel extends GeometryLabel { + protected setLabelPosition(labelPointCfg: LabelPointCfg, mappingData: MappingDatum, index: number, position: string) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const transposed = coordinate.isTransposed; const shapePoints = mappingData.points as Point[]; const point0 = coordinate.convert(shapePoints[0]); diff --git a/src/geometry/label/pie.ts b/src/geometry/label/pie.ts index bb85cd1068..8f8792fc30 100644 --- a/src/geometry/label/pie.ts +++ b/src/geometry/label/pie.ts @@ -103,8 +103,12 @@ function antiCollision(labels, lineHeight, plotRange, center, isRight) { export default class PieLabel extends PolarLabel { constructor(geometry: Geometry) { super(geometry); - this.defaultLabelCfg = get(geometry.theme, 'pieLabels', {}); } + + protected getDefaultLabelCfg() { + return get(this.geometry.theme, 'pieLabels', {}); + } + protected getDefaultOffset(offset) { return offset || 0; } @@ -119,7 +123,7 @@ export default class PieLabel extends PolarLabel { // 连接线 protected lineToLabel(label: LabelItem) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); // @ts-ignore const r = coordinate.getRadius(); const distance = label.offset; @@ -154,7 +158,7 @@ export default class PieLabel extends PolarLabel { } protected getLabelAlign(point: LabelItem) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const center = coordinate.getCenter(); let align; @@ -179,7 +183,7 @@ export default class PieLabel extends PolarLabel { } protected getPointAngle(point) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const startPoint = { x: isArray(point.x) ? point.x[0] : point.x, y: point.y[0], @@ -204,7 +208,7 @@ export default class PieLabel extends PolarLabel { } protected getCirclePoint(angle, offset, p?) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const center = coordinate.getCenter(); // @ts-ignore const r = coordinate.getRadius() + offset; @@ -217,7 +221,7 @@ export default class PieLabel extends PolarLabel { // distribute labels private distribute(labels, offset) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); // @ts-ignore const radius = coordinate.getRadius(); const lineHeight = get(this.geometry.theme, ['pieLabels', 'labelHeight'], 14); diff --git a/src/geometry/label/polar.ts b/src/geometry/label/polar.ts index e67fe9cd80..f4cf88b675 100644 --- a/src/geometry/label/polar.ts +++ b/src/geometry/label/polar.ts @@ -17,7 +17,7 @@ export default class PolarLabel extends GeometryLabel { * @param point */ protected getLabelAlign(point: LabelItem) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); let align; if (point.labelEmit) { align = (point.angle <= Math.PI / 2 && point.angle > -Math.PI / 2) ? 'left' : 'right'; @@ -104,7 +104,7 @@ export default class PolarLabel extends GeometryLabel { * @param point */ protected getPointAngle(point: Point): number { - return getAngleByPoint(this.coordinate, point); + return getAngleByPoint(this.getCoordinate(), point); } /** @@ -115,7 +115,7 @@ export default class PolarLabel extends GeometryLabel { * @param isLabelEmit */ protected getCirclePoint(angle: number, offset: number, point: Point, isLabelEmit: boolean) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const center = coordinate.getCenter(); let r = getDistanceToCenter(coordinate, point); if (r === 0) { @@ -163,7 +163,7 @@ export default class PolarLabel extends GeometryLabel { // 获取中心的位置 private getMiddlePoint(points: Point[]) { - const coordinate = this.coordinate; + const coordinate = this.getCoordinate(); const count = points.length; let middlePoint = { x: 0, diff --git a/tests/unit/geometry/label/labels-spec.ts b/tests/unit/geometry/label/labels-spec.ts index 7a27afac94..192fd56378 100644 --- a/tests/unit/geometry/label/labels-spec.ts +++ b/tests/unit/geometry/label/labels-spec.ts @@ -82,7 +82,7 @@ describe('LabelsRenderer', () => { it('render', () => { expect(interval.labelsContainer.getCount()).toBe(3); // @ts-ignore - const labelsRenderer = interval.labelsRenderer; + const labelsRenderer = interval.geometryLabel.labelsRenderer; expect(labelsRenderer.container.getCount()).toBe(3); // @ts-ignore expect(labelsRenderer.container.getFirst().getCount()).toBe(2); @@ -113,7 +113,7 @@ describe('LabelsRenderer', () => { interval.paint(); // @ts-ignore - const labelsRenderer = interval.labelsRenderer; + const labelsRenderer = interval.geometryLabel.labelsRenderer; expect(labelsRenderer.container.getCount()).toBe(2); expect(labelsRenderer.container.find(ele => ele.get('type') === 'text').get('data')).toEqual({ a: '1', percent: 0.5 }); expect(labelsRenderer.container.find(ele => ele.get('type') === 'text').get('animateCfg').update).toBe(false); @@ -126,7 +126,7 @@ describe('LabelsRenderer', () => { it('clear', () => { // @ts-ignore - const labelsRenderer = interval.labelsRenderer; + const labelsRenderer = interval.geometryLabel.labelsRenderer; labelsRenderer.clear(); expect(interval.labelsContainer.getCount()).toBe(0); @@ -137,7 +137,7 @@ describe('LabelsRenderer', () => { it('destroy', () => { // @ts-ignore - const labelsRenderer = interval.labelsRenderer; + const labelsRenderer = interval.geometryLabel.labelsRenderer; labelsRenderer.destroy(); expect(interval.labelsContainer.destroyed).toBe(true); From 402d6c52c44e83a478abfb3c0567526e22414a1f Mon Sep 17 00:00:00 2001 From: simaQ Date: Mon, 16 Mar 2020 22:48:23 +0800 Subject: [PATCH 08/16] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=20Label=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=B0=86=E9=A5=BC?= =?UTF-8?q?=E5=9B=BE=20label=20=E7=9A=84=E5=B8=83=E5=B1=80=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=88=B0=20LabelLayout=20=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/component/labels.ts | 100 ++++++---- src/geometry/label/base.ts | 78 ++------ src/geometry/label/index.ts | 4 +- src/geometry/label/layout/distribute.ts | 188 +++++++++++++++++++ src/geometry/label/layout/limit-in-canvas.ts | 3 +- src/geometry/label/layout/limit-in-shape.ts | 3 +- src/geometry/label/layout/overlap.ts | 5 +- src/geometry/label/pie.ts | 180 +----------------- src/index.ts | 2 + 9 files changed, 283 insertions(+), 280 deletions(-) create mode 100644 src/geometry/label/layout/distribute.ts diff --git a/src/component/labels.ts b/src/component/labels.ts index f94b640da8..f4179ab683 100644 --- a/src/component/labels.ts +++ b/src/component/labels.ts @@ -50,20 +50,28 @@ export default class Labels { this.shapesMap = {}; const container = this.container; const offscreenGroup = this.createOffscreenGroup(); // 创建虚拟分组 - // 在虚拟 group 中创建 shapes + // step 1: 在虚拟 group 中创建 shapes for (const item of items) { if (item) { this.renderLabel(item, offscreenGroup); } } - this.adjustLabels(shapes); // 调整 labels + + // step 2: 根据布局,调整 labels + this.doLayout(items, shapes); + + // step 3: 绘制 labelLine + this.renderLabelLine(items); + + // step 4: 根据用户设置的偏移量调整 label + this.adjustLabel(items); // 进行添加、更新、销毁操作 const lastShapesMap = this.lastShapesMap; const shapesMap = this.shapesMap; each(shapesMap, (shape, id) => { if (shape.destroyed) { - // label 在布局调整环节被删除了(adjustLabels) + // label 在布局调整环节被删除了(doLayout) delete shapesMap[id]; } else { if (lastShapesMap[id]) { @@ -180,6 +188,7 @@ export default class Labels { translate(content, x, y); // 将 label 平移至 x, y 指定的位置 labelShape = content; + content.set('_name', 'labelContent'); labelGroup.add(content); } else { labelShape = labelGroup.addShape('text', { @@ -192,18 +201,18 @@ export default class Labels { ...cfg.style, }, ...shapeAppendCfg, + _name: 'labelContent', }); } if (cfg.rotate) { rotate(labelShape, cfg.rotate); } - this.drawLabelLine(cfg, labelGroup); this.shapesMap[id] = labelGroup; } // 根据type对label布局 - private adjustLabels(shapes) { + private doLayout(items: LabelItem[], shapes: Record) { if (this.layout) { const layouts = isArray(this.layout) ? this.layout : [this.layout]; each(layouts, (layout: GeometryLabelLayoutCfg) => { @@ -216,38 +225,47 @@ export default class Labels { geometryShapes.push(shapes[id]); }); - layoutFn(labelShapes, geometryShapes, this.region, layout.cfg); + layoutFn(items, labelShapes, geometryShapes, this.region, layout.cfg); } }); } } - private drawLabelLine(labelCfg: LabelItem, container: IGroup) { - if (!labelCfg.labelLine) { - // labelLine: null | false,关闭 label 对应的 labelLine - return; - } - const labelLineCfg = get(labelCfg, 'labelLine', {}); - let path = labelLineCfg.path; - if (!path) { - const start = labelCfg.start; - path = [ - ['M', start.x, start.y], - ['L', labelCfg.x, labelCfg.y], - ]; - } - container.addShape('path', { - capture: false, // labelLine 默认不参与事件捕获 - attrs: { - path, - stroke: labelCfg.color ? labelCfg.color : get(labelCfg, ['style', 'fill'], '#000'), - fill: null, - ...labelLineCfg.style, - }, - id: labelCfg.id, - origin: labelCfg.mappingData, - data: labelCfg.data, - coordinate: labelCfg.coordinate, + private renderLabelLine(labelItems: LabelItem[]) { + each(labelItems, (labelItem) => { + if (!labelItem) { + return; + } + if (!labelItem.labelLine) { + // labelLine: null | false,关闭 label 对应的 labelLine + return; + } + const labelLineCfg = get(labelItem, 'labelLine', {}); + const id = labelItem.id; + let path = labelLineCfg.path; + if (!path) { + const start = labelItem.start; + path = [ + ['M', start.x, start.y], + ['L', labelItem.x, labelItem.y], + ]; + } + const labelGroup = this.shapesMap[id]; + if (!labelGroup.destroyed) { + labelGroup.addShape('path', { + capture: false, // labelLine 默认不参与事件捕获 + attrs: { + path, + stroke: labelItem.color ? labelItem.color : get(labelItem, ['style', 'fill'], '#000'), + fill: null, + ...labelLineCfg.style, + }, + id, + origin: labelItem.mappingData, + data: labelItem.data, + coordinate: labelItem.coordinate, + }); + } }); } @@ -257,4 +275,22 @@ export default class Labels { const newGroup = new GroupClass({}); return newGroup; } + + private adjustLabel(items: LabelItem[]) { + each(items, (item) => { + if (item) { + const id = item.id; + const labelGroup = this.shapesMap[id]; + if (!labelGroup.destroyed) { + const labelShape = labelGroup.find(ele => ele.get('_name') === 'labelContent'); + if (item.offsetX) { + labelShape.attr('x', labelShape.attr('x') + item.offsetX); + } + if (item.offsetY) { + labelShape.attr('y', labelShape.attr('y') + item.offsetY); + } + } + } + }); + } } diff --git a/src/geometry/label/base.ts b/src/geometry/label/base.ts index 46f76684cd..a2936b3bba 100644 --- a/src/geometry/label/base.ts +++ b/src/geometry/label/base.ts @@ -1,8 +1,8 @@ import { deepMix, each, get, isArray, isFunction, isNil, isNumber, isUndefined } from '@antv/util'; import { FIELD_ORIGIN } from '../../constant'; -import { Coordinate, Scale } from '../../dependents'; -import { Datum, LabelOption, LooseObject, MappingDatum, Point } from '../../interface'; +import { Scale } from '../../dependents'; +import { Datum, LabelOption, MappingDatum, Point } from '../../interface'; import { LabelCfg, LabelItem, LabelPointCfg, TextAlign } from './interface'; import { getDefaultAnimateCfg } from '../../animate'; @@ -29,16 +29,15 @@ export default class GeometryLabel { /** geometry 实例 */ public readonly geometry: Geometry; public labelsRenderer: Labels; + /** 默认的布局 */ + public defaultLayout: string; constructor(geometry: Geometry) { this.geometry = geometry; } public render(mapppingArray: MappingDatum[], isUpdate: boolean) { - let labelItems = this.getItems(mapppingArray); - labelItems = this.adjustItems(labelItems); - - this.drawLines(labelItems); + const labelItems = this.getItems(mapppingArray); const labelsRenderer = this.getLabelsRenderer(); const shapes = this.getGeometryShapes(); @@ -66,18 +65,6 @@ export default class GeometryLabel { return this.geometry.coordinate; } - /** - * 根据当前 shape 对应的映射数据获取对应的 label 配置信息。 - * @param mapppingArray 映射后的绘制数据 - * @returns - */ - public getLabelItems(mapppingArray: MappingDatum[]) { - const items = this.adjustItems(this.getItems(mapppingArray)); - this.drawLines(items); - - return items; - } - /** * 获取 label 的默认配置 */ @@ -99,53 +86,6 @@ export default class GeometryLabel { position: string ) {} - /** - * 生成文本线配置 - * @param item - */ - protected lineToLabel(item: LabelItem) {} - - /** - * 根据用户设置的 offsetX 和 offsetY 调整 label 的 x 和 y 坐标 - * @param items - * @returns - */ - protected adjustItems(items: LabelItem[]) { - each(items, (item) => { - if (!item) { - return; - } - if (item.offsetX) { - item.x += item.offsetX; - } - if (item.offsetY) { - item.y += item.offsetY; - } - }); - return items; - } - - /** - * 绘制 label 文本连接线 - * @param items - */ - protected drawLines(items: LabelItem[]) { - each(items, (item) => { - if (!item) { - return; - } - - if (item.offset <= 0) { - // 内部文本不绘制 labelLine - item.labelLine = null; - } - - if (item.labelLine) { - this.lineToLabel(item); - } - }); - } - /** * 获取文本默认偏移量 * @param offset @@ -331,7 +271,9 @@ export default class GeometryLabel { if (!labelsRenderer) { labelsRenderer = new Labels({ container: labelsContainer, - layout: get(labelOption, ['cfg', 'layout']), + layout: get(labelOption, ['cfg', 'layout'], { + type: this.defaultLayout, + }), }); this.labelsRenderer = labelsRenderer; } @@ -370,6 +312,10 @@ export default class GeometryLabel { item.textAlign = this.getLabelAlign(item, subIndex, total); } + if (item.offset <= 0) { + item.labelLine = null; + } + items.push(item); }); }); diff --git a/src/geometry/label/index.ts b/src/geometry/label/index.ts index 21d3225792..bf9eb21a7e 100644 --- a/src/geometry/label/index.ts +++ b/src/geometry/label/index.ts @@ -1,15 +1,17 @@ import { BBox, IGroup, IShape } from '../../dependents'; import { LooseObject } from '../../interface'; import { GeometryLabelConstructor } from './base'; +import { LabelItem } from './interface'; /** * label 布局函数定义 + * @param items 存储每个 label 的详细信息 * @param labels 所有的 labels 图形实例 * @param shapes 所有 label 对应的图形元素实例 * @param region 画布区域 * @param cfg 用于存储各个布局函数开放给用户的配置数据 */ -type GeometryLabelsLayoutFn = (labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox, cfg?: LooseObject) => void; +type GeometryLabelsLayoutFn = (items: LabelItem[], labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox, cfg?: LooseObject) => void; const GEOMETRY_LABELS_MAP: Record = {}; const GEOMETRY_LABELS_LAYOUT_MAP: Record = {}; diff --git a/src/geometry/label/layout/distribute.ts b/src/geometry/label/layout/distribute.ts new file mode 100644 index 0000000000..c86788222c --- /dev/null +++ b/src/geometry/label/layout/distribute.ts @@ -0,0 +1,188 @@ +import { isObject, each, get } from '@antv/util'; + +import { BBox, IGroup, IShape } from '../../../dependents'; +import { LabelItem } from '../interface'; + +import { polarToCartesian } from '../../../util/graphics'; + +/** label text和line距离 4px */ +const MARGIN = 4; + +function antiCollision(labelShapes, labels, lineHeight, plotRange, center, isRight) { + // adjust y position of labels to avoid overlapping + let overlapping = true; + const start = plotRange.start; + const end = plotRange.end; + const startY = Math.min(start.y, end.y); + let totalHeight = Math.abs(start.y - end.y); + let i; + + let maxY = 0; + let minY = Number.MIN_VALUE; + const boxes = labels.map((label) => { + if (label.y > maxY) { + maxY = label.y; + } + if (label.y < minY) { + minY = label.y; + } + return { + size: lineHeight, + targets: [label.y - startY], + }; + }); + minY -= startY; + if (maxY - startY > totalHeight) { + totalHeight = maxY - startY; + } + + while (overlapping) { + /* eslint no-loop-func: 0 */ + boxes.forEach((box) => { + const target = (Math.min.apply(minY, box.targets) + Math.max.apply(minY, box.targets)) / 2; + box.pos = Math.min(Math.max(minY, target - box.size / 2), totalHeight - box.size); + // box.pos = Math.max(0, target - box.size / 2); + }); + + // detect overlapping and join boxes + overlapping = false; + i = boxes.length; + while (i--) { + if (i > 0) { + const previousBox = boxes[i - 1]; + const box = boxes[i]; + if (previousBox.pos + previousBox.size > box.pos) { + // overlapping + previousBox.size += box.size; + previousBox.targets = previousBox.targets.concat(box.targets); + + // overflow, shift up + if (previousBox.pos + previousBox.size > totalHeight) { + previousBox.pos = totalHeight - previousBox.size; + } + boxes.splice(i, 1); // removing box + overlapping = true; + } + } + } + } + + i = 0; + // step 4: normalize y and adjust x + boxes.forEach((b) => { + let posInCompositeBox = startY + lineHeight / 2; // middle of the label + b.targets.forEach(() => { + labels[i].y = b.pos + posInCompositeBox; + posInCompositeBox += lineHeight; + i++; + }); + }); + + const labelsMap = {}; + for (const labelShape of labelShapes) { + labelsMap[labelShape.get('id')] = labelShape; + } + + // (x - cx)^2 + (y - cy)^2 = totalR^2 + labels.forEach((label) => { + const rPow2 = label.r * label.r; + const dyPow2 = Math.pow(Math.abs(label.y - center.y), 2); + if (rPow2 < dyPow2) { + label.x = center.x; + } else { + const dx = Math.sqrt(rPow2 - dyPow2); + if (!isRight) { + // left + label.x = center.x - dx; + } else { + // right + label.x = center.x + dx; + } + } + + // adjust labelShape + const labelShape = labelsMap[label.id]; + labelShape.attr('x', label.x); + labelShape.attr('y', label.y); + }); +} + +export function distribute(items: LabelItem[], labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { + const offset = items[0] ? items[0].offset : 0; + const coordinate = labels[0].get('coordinate'); + // @ts-ignore + const radius = coordinate.getRadius(); + const center = coordinate.getCenter(); + + if (offset > 0) { + // const lineHeight = get(this.geometry.theme, ['pieLabels', 'labelHeight'], 14); + const lineHeight = 14; // TODO + const totalR = radius + offset; + const totalHeight = totalR * 2 + lineHeight * 2; + const plotRange = { + start: coordinate.start, + end: coordinate.end, + }; + + // step 1: separate labels + const halves = [ + [], // left + [], // right + ]; + items.forEach((labelItem) => { + if (!labelItem) { + return; + } + if (labelItem.textAlign === 'right') { + // left + halves[0].push(labelItem); + } else { + // right or center will be put on the right side + halves[1].push(labelItem); + } + }); + + halves.forEach((half, index) => { + // step 2: reduce labels + const maxLabelsCountForOneSide = totalHeight / lineHeight; + if (half.length > maxLabelsCountForOneSide) { + half.sort((a, b) => { + // sort by percentage DESC + return b['..percent'] - a['..percent']; + }); + half.splice(maxLabelsCountForOneSide, half.length - maxLabelsCountForOneSide); + } + + // step 3: distribute position (x and y) + half.sort((a, b) => { + // sort by y ASC + return a.y - b.y; + }); + + + antiCollision(labels, half, lineHeight, plotRange, center, index); + }); + } + + // 配置 labelLine + each(items, item => { + if (item && item.labelLine) { + const distance = item.offset; + const angle = item.angle; + // 贴近圆周 + const startPoint = polarToCartesian(center.x, center.y, radius, angle); + const innerPoint = polarToCartesian(center.x, center.y, radius + distance / 2, angle); + const itemX = item.x + get(item, 'offsetX', 0); + const itemY = item.y + get(item, 'offsetY', 0); + const endPoint = { + x: itemX - Math.cos(angle) * MARGIN, + y: itemY - Math.sin(angle) * MARGIN, + }; + if (!isObject(item.labelLine)) { + // labelLine: true + item.labelLine = {}; + } + item.labelLine.path = [`M ${startPoint.x}`, `${startPoint.y} Q${innerPoint.x}`, `${innerPoint.y} ${endPoint.x}`, endPoint.y].join(','); + } + }); +} diff --git a/src/geometry/label/layout/limit-in-canvas.ts b/src/geometry/label/layout/limit-in-canvas.ts index 8a278f2059..b08d62939b 100644 --- a/src/geometry/label/layout/limit-in-canvas.ts +++ b/src/geometry/label/layout/limit-in-canvas.ts @@ -1,6 +1,7 @@ import { each } from '@antv/util'; import { BBox, IGroup, IShape } from '../../../dependents'; import { translate } from '../../../util/transform'; +import { LabelItem } from '../interface'; /** * @ignore @@ -8,7 +9,7 @@ import { translate } from '../../../util/transform'; * @param labels * @param cfg */ -export function limitInCanvas(labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { +export function limitInCanvas(items: LabelItem[], labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { each(labels, (label: IGroup) => { const { minX: regionMinX, minY: regionMinY, maxX: regionMaxX, maxY: regionMaxY } = region; const { minX, minY, maxX, maxY, x, y, width, height } = label.getCanvasBBox(); diff --git a/src/geometry/label/layout/limit-in-shape.ts b/src/geometry/label/layout/limit-in-shape.ts index 3b87af6044..5216512d22 100644 --- a/src/geometry/label/layout/limit-in-shape.ts +++ b/src/geometry/label/layout/limit-in-shape.ts @@ -1,11 +1,12 @@ import { each } from '@antv/util'; import { BBox, IGroup, IShape } from '../../../dependents'; +import { LabelItem } from '../interface'; /** * @ignore * 根据图形元素以及 label 的 bbox 进行调整,如果 label 超出了 shape 的 bbox 则不展示 */ -export function limitInShape(labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { +export function limitInShape(items: LabelItem[], labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { each(labels, (label, index) => { const labelBBox = label.getCanvasBBox(); // 文本有可能发生旋转 const shapeBBox = shapes[index].getBBox(); diff --git a/src/geometry/label/layout/overlap.ts b/src/geometry/label/layout/overlap.ts index fdac2bd1bf..5a8e8845f1 100644 --- a/src/geometry/label/layout/overlap.ts +++ b/src/geometry/label/layout/overlap.ts @@ -1,5 +1,6 @@ import { each } from '@antv/util'; import { BBox, IGroup, IShape } from '../../../dependents'; +import { LabelItem } from '../interface'; const MAX_TIMES = 100; @@ -211,7 +212,7 @@ function adjustLabelPosition(label: IShape, x: number, y: number, index: number) * 不同于 'overlap' 类型的布局,该布局不会对 label 的位置进行偏移调整。 * @param labels 参与布局调整的 label 数组集合 */ -export function fixedOverlap(labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { +export function fixedOverlap(items: LabelItem[], labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { const greedy = new Greedy(); each(labels, (label: IGroup) => { const labelShape = label.find((shape) => shape.get('type') === 'text') as IShape; @@ -227,7 +228,7 @@ export function fixedOverlap(labels: IGroup[], shapes: IShape[] | IGroup[], regi * label 防遮挡布局:为了防止 label 之间相互覆盖同时保证尽可能多 的 label 展示,通过尝试将 label 向**四周偏移**来剔除放不下的 label * @param labels 参与布局调整的 label 数组集合 */ -export function overlap(labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { +export function overlap(items: LabelItem[], labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) { const greedy = new Greedy(); each(labels, (label: IGroup) => { const labelShape = label.find((shape) => shape.get('type') === 'text') as IShape; diff --git a/src/geometry/label/pie.ts b/src/geometry/label/pie.ts index 8f8792fc30..67ec975a55 100644 --- a/src/geometry/label/pie.ts +++ b/src/geometry/label/pie.ts @@ -1,106 +1,16 @@ -import { get, isArray, isObject } from '@antv/util'; +import { get, isArray } from '@antv/util'; import { getAngleByPoint } from '../../util/coordinate'; import { polarToCartesian } from '../../util/graphics'; import Geometry from '../base'; import { LabelItem } from './interface'; import PolarLabel from './polar'; -/** label text和line距离 4px */ -const MARGIN = 4; - -function antiCollision(labels, lineHeight, plotRange, center, isRight) { - // adjust y position of labels to avoid overlapping - let overlapping = true; - const start = plotRange.start; - const end = plotRange.end; - const startY = Math.min(start.y, end.y); - let totalHeight = Math.abs(start.y - end.y); - let i; - - let maxY = 0; - let minY = Number.MIN_VALUE; - const boxes = labels.map((label) => { - if (label.y > maxY) { - maxY = label.y; - } - if (label.y < minY) { - minY = label.y; - } - return { - size: lineHeight, - targets: [label.y - startY], - }; - }); - minY -= startY; - if (maxY - startY > totalHeight) { - totalHeight = maxY - startY; - } - - while (overlapping) { - /* eslint no-loop-func: 0 */ - boxes.forEach((box) => { - const target = (Math.min.apply(minY, box.targets) + Math.max.apply(minY, box.targets)) / 2; - box.pos = Math.min(Math.max(minY, target - box.size / 2), totalHeight - box.size); - // box.pos = Math.max(0, target - box.size / 2); - }); - - // detect overlapping and join boxes - overlapping = false; - i = boxes.length; - while (i--) { - if (i > 0) { - const previousBox = boxes[i - 1]; - const box = boxes[i]; - if (previousBox.pos + previousBox.size > box.pos) { - // overlapping - previousBox.size += box.size; - previousBox.targets = previousBox.targets.concat(box.targets); - - // overflow, shift up - if (previousBox.pos + previousBox.size > totalHeight) { - previousBox.pos = totalHeight - previousBox.size; - } - boxes.splice(i, 1); // removing box - overlapping = true; - } - } - } - } - - i = 0; - // step 4: normalize y and adjust x - boxes.forEach((b) => { - let posInCompositeBox = startY + lineHeight / 2; // middle of the label - b.targets.forEach(() => { - labels[i].y = b.pos + posInCompositeBox; - posInCompositeBox += lineHeight; - i++; - }); - }); - - // (x - cx)^2 + (y - cy)^2 = totalR^2 - labels.forEach((label) => { - const rPow2 = label.r * label.r; - const dyPow2 = Math.pow(Math.abs(label.y - center.y), 2); - if (rPow2 < dyPow2) { - label.x = center.x; - } else { - const dx = Math.sqrt(rPow2 - dyPow2); - if (!isRight) { - // left - label.x = center.x - dx; - } else { - // right - label.x = center.x + dx; - } - } - }); -} - /** * 饼图 label */ export default class PieLabel extends PolarLabel { + public defaultLayout = 'distribute'; + constructor(geometry: Geometry) { super(geometry); } @@ -113,36 +23,6 @@ export default class PieLabel extends PolarLabel { return offset || 0; } - protected adjustItems(items: LabelItem[]) { - const offset = items[0] ? items[0].offset : 0; - if (offset > 0) { - items = this.distribute(items, offset); - } - return super.adjustItems(items); - } - - // 连接线 - protected lineToLabel(label: LabelItem) { - const coordinate = this.getCoordinate(); - // @ts-ignore - const r = coordinate.getRadius(); - const distance = label.offset; - const angle = label.angle; - const center = coordinate.getCenter(); - // 贴近圆周 - const start = polarToCartesian(center.x, center.y, r, angle); - const inner = polarToCartesian(center.x, center.y, r + distance / 2, angle); - const end = { - x: label.x - Math.cos(angle) * MARGIN, - y: label.y - Math.sin(angle) * MARGIN, - }; - if (!isObject(label.labelLine)) { - // labelLine: true - label.labelLine = {}; - } - label.labelLine.path = [`M ${start.x}`, `${start.y} Q${inner.x}`, `${inner.y} ${end.x}`, end.y].join(','); - } - protected getLabelRotate(angle: number, offset: number, isLabelLimit: boolean) { let rotate; if (offset < 0) { @@ -218,58 +98,4 @@ export default class PieLabel extends PolarLabel { r, }; } - - // distribute labels - private distribute(labels, offset) { - const coordinate = this.getCoordinate(); - // @ts-ignore - const radius = coordinate.getRadius(); - const lineHeight = get(this.geometry.theme, ['pieLabels', 'labelHeight'], 14); - const center = coordinate.getCenter(); - const totalR = radius + offset; - const totalHeight = totalR * 2 + lineHeight * 2; - const plotRange = { - start: coordinate.start, - end: coordinate.end, - }; - - // step 1: separate labels - const halves = [ - [], // left - [], // right - ]; - labels.forEach((label) => { - if (!label) { - return; - } - if (label.textAlign === 'right') { - // left - halves[0].push(label); - } else { - // right or center will be put on the right side - halves[1].push(label); - } - }); - - halves.forEach((half, index) => { - // step 2: reduce labels - const maxLabelsCountForOneSide = totalHeight / lineHeight; - if (half.length > maxLabelsCountForOneSide) { - half.sort((a, b) => { - // sort by percentage DESC - return b['..percent'] - a['..percent']; - }); - half.splice(maxLabelsCountForOneSide, half.length - maxLabelsCountForOneSide); - } - - // step 3: distribute position (x and y) - half.sort((a, b) => { - // sort by y ASC - return a.y - b.y; - }); - antiCollision(half, lineHeight, plotRange, center, index); - }); - - return halves[0].concat(halves[1]); - } } diff --git a/src/index.ts b/src/index.ts index 39842e67c7..923cc2cafd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,11 +76,13 @@ registerGeometryLabel('polar', PolarLabel); // 注册 Geometry label 内置的布局函数 import { registerGeometryLabelLayout } from './core'; +import { distribute } from './geometry/label/layout/distribute'; import { limitInCanvas } from './geometry/label/layout/limit-in-canvas'; import { limitInShape } from './geometry/label/layout/limit-in-shape'; import { fixedOverlap, overlap } from './geometry/label/layout/overlap'; registerGeometryLabelLayout('overlap', overlap); +registerGeometryLabelLayout('distribute', distribute); registerGeometryLabelLayout('fixed-overlap', fixedOverlap); registerGeometryLabelLayout('limit-in-shape', limitInShape); registerGeometryLabelLayout('limit-in-canvas', limitInCanvas); From 78323aae95913d24294258de35a74f73d6c82e3f Mon Sep 17 00:00:00 2001 From: simaQ Date: Mon, 16 Mar 2020 22:49:29 +0800 Subject: [PATCH 09/16] =?UTF-8?q?fix:=20=E9=BB=98=E8=AE=A4=E9=A5=BC?= =?UTF-8?q?=E5=9B=BE=20label=20=E8=BF=9E=E6=8E=A5=E7=BA=BF=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E5=90=8C=E5=9B=BE=E5=BD=A2=E5=85=83=E7=B4=A0=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/theme/default.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/theme/default.ts b/src/theme/default.ts index cecbe9b0ea..9d16a92605 100644 --- a/src/theme/default.ts +++ b/src/theme/default.ts @@ -1253,7 +1253,7 @@ export function getThemeByStylesheet(styleSheet: StyleSheet) { labelLine: { style: { lineWidth: styleSheet.labelLineBorder, - stroke: styleSheet.labelLineBorderColor, + // stroke: styleSheet.labelLineBorderColor, }, }, autoRotate: true, From 5dd7fb16bc845b94ab4fe7596e06c6c0ad116f43 Mon Sep 17 00:00:00 2001 From: simaQ Date: Mon, 16 Mar 2020 22:49:49 +0800 Subject: [PATCH 10/16] fix: lint fix --- src/interaction/action/element/state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interaction/action/element/state.ts b/src/interaction/action/element/state.ts index a7001a84d6..14f6658cbd 100644 --- a/src/interaction/action/element/state.ts +++ b/src/interaction/action/element/state.ts @@ -1,7 +1,7 @@ import { each, isNil } from '@antv/util'; import { ListItem } from '../../../dependents'; import Element from '../../../geometry/element/'; -import { getCurrentElement, isElementChange, getDelegationObject, getElements, getElementValue, isList, getScaleByField} from '../util'; +import { getCurrentElement, getDelegationObject, getElements, getElementValue, getScaleByField, isElementChange, isList} from '../util'; import StateBase from './state-base'; function getItem(shape) { From 4fa8f9a41802468ac7d6bb645c8b98bcf73c3165 Mon Sep 17 00:00:00 2001 From: simaQ Date: Mon, 16 Mar 2020 22:50:16 +0800 Subject: [PATCH 11/16] =?UTF-8?q?chore:=20demo=20=E7=BC=BA=E5=B0=91=20heig?= =?UTF-8?q?ht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/pie/basic/demo/pie-texture.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/pie/basic/demo/pie-texture.ts b/examples/pie/basic/demo/pie-texture.ts index 6ef0b2ab0c..cf9d5d30c3 100644 --- a/examples/pie/basic/demo/pie-texture.ts +++ b/examples/pie/basic/demo/pie-texture.ts @@ -8,6 +8,7 @@ const data = [ const chart = new Chart({ container: 'container', autoFit: true, + height: 500, }); chart.data(data); From d43d5ce41375566e03138c36f3f9b60a863e34ea Mon Sep 17 00:00:00 2001 From: simaQ Date: Mon, 16 Mar 2020 22:50:41 +0800 Subject: [PATCH 12/16] =?UTF-8?q?test:=20=E6=9B=B4=E6=96=B0=20label=20layo?= =?UTF-8?q?ut=20=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/geometry/label/layout/limit-in-canvas-spec.ts | 2 +- tests/unit/geometry/label/layout/limit-in-shape-spec.ts | 2 +- tests/unit/geometry/label/layout/overlap-spec.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/geometry/label/layout/limit-in-canvas-spec.ts b/tests/unit/geometry/label/layout/limit-in-canvas-spec.ts index 890f835bc3..09c53358c3 100644 --- a/tests/unit/geometry/label/layout/limit-in-canvas-spec.ts +++ b/tests/unit/geometry/label/layout/limit-in-canvas-spec.ts @@ -89,7 +89,7 @@ describe('GeometryLabel layout', () => { expect(canvas.getCanvasBBox().minX).toBeLessThan(0); // @ts-ignore - limitInCanvas(canvas.getChildren(), [], region); + limitInCanvas([], canvas.getChildren(), [], region); canvas.draw(); expect(canvas.getChildren().length).toBe(7); expect(canvas.getCanvasBBox().minX).toBe(0); diff --git a/tests/unit/geometry/label/layout/limit-in-shape-spec.ts b/tests/unit/geometry/label/layout/limit-in-shape-spec.ts index 05922d1659..84a09cff18 100644 --- a/tests/unit/geometry/label/layout/limit-in-shape-spec.ts +++ b/tests/unit/geometry/label/layout/limit-in-shape-spec.ts @@ -23,7 +23,7 @@ describe('GeometryLabel layout', () => { ]; // @ts-ignore - limitInShape(labels, shapes, {}); + limitInShape([], labels, shapes, {}); expect(removedCount).toBe(2); }); diff --git a/tests/unit/geometry/label/layout/overlap-spec.ts b/tests/unit/geometry/label/layout/overlap-spec.ts index 4fa6dd6ad1..8844d9b878 100644 --- a/tests/unit/geometry/label/layout/overlap-spec.ts +++ b/tests/unit/geometry/label/layout/overlap-spec.ts @@ -29,7 +29,7 @@ describe('GeometryLabel layout', () => { expect(canvas.getChildren().length).toBe(20); // @ts-ignore - fixedOverlap(labels, [], {}); + fixedOverlap([], labels, [], {}); canvas.draw(); expect(canvas.getChildren().length).toBeLessThan(20); @@ -56,7 +56,7 @@ describe('GeometryLabel layout', () => { expect(canvas.getChildren().length).toBe(20); // @ts-ignore - overlap(labels, [], {}); + overlap([], labels, [], {}); canvas.draw(); expect(canvas.getChildren().length).toBe(9); From cfd7c0a5e619f2cbb0dd790794913ac28915665a Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 17 Mar 2020 12:46:22 +0800 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=BC=8F?= =?UTF-8?q?=E6=96=97=E5=9B=BElabel=20=E9=97=AE=E9=A2=98=EF=BC=9A=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=BB=A5=E5=8F=8A=E5=B0=96=E5=BA=95=E6=BC=8F=E6=96=97?= =?UTF-8?q?=E5=9B=BE=E6=9C=80=E5=90=8E=E4=B8=80=E4=B8=AAlabel=E7=9A=84?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E9=94=99=E8=AF=AF=E3=80=82Closed=20#1847?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/geometry/label/base.ts | 124 +++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 67 deletions(-) diff --git a/src/geometry/label/base.ts b/src/geometry/label/base.ts index a2936b3bba..2b61e8165d 100644 --- a/src/geometry/label/base.ts +++ b/src/geometry/label/base.ts @@ -36,8 +36,46 @@ export default class GeometryLabel { this.geometry = geometry; } - public render(mapppingArray: MappingDatum[], isUpdate: boolean) { - const labelItems = this.getItems(mapppingArray); + public getLabelItems(mapppingArray: MappingDatum[]): LabelItem[] { + const items = []; + const labelCfgs = this.getLabelCfgs(mapppingArray); + // 获取 label 相关的 x,y 的值,获取具体的 x, y,防止存在数组 + each(mapppingArray, (mappingData: MappingDatum, index: number) => { + const labelCfg = labelCfgs[index]; + if (!labelCfg) { + items.push(null); + return; + } + + const labelContent = !isArray(labelCfg.content) ? [labelCfg.content] : labelCfg.content; + labelCfg.content = labelContent; + const total = labelContent.length; + each(labelContent, (content, subIndex) => { + if (isNil(content) || content === '') { + items.push(null); + return; + } + + const item = { + ...labelCfg, + ...this.getLabelPoint(labelCfg, mappingData, subIndex), + }; + if (!item.textAlign) { + item.textAlign = this.getLabelAlign(item, subIndex, total); + } + + if (item.offset <= 0) { + item.labelLine = null; + } + + items.push(item); + }); + }); + return items; + } + + public render(mapppingArray: MappingDatum[], isUpdate: boolean = false) { + const labelItems = this.getLabelItems(mapppingArray); const labelsRenderer = this.getLabelsRenderer(); const shapes = this.getGeometryShapes(); @@ -168,33 +206,23 @@ export default class GeometryLabel { label.y = getDimValue(mappingData.y, index); } - // get nearest point of the shape as the label line start point - if ( - mappingData && - mappingData.nextPoints && - ['funnel', 'pyramid'].includes(isArray(mappingData.shape) ? mappingData.shape[0] : mappingData.shape) - ) { - let maxX = -Infinity; - mappingData.nextPoints.forEach((p) => { - const p1 = coordinate.convert(p); - if (p1.x > maxX) { - maxX = p1.x; - } - }); - label.x = (label.x + maxX) / 2; - } - // sharp edge of the pyramid - if (mappingData.shape === 'pyramid' && !mappingData.nextPoints && mappingData.points) { - (mappingData.points as Point[]).forEach((p: Point) => { - let p1 = p; - p1 = coordinate.convert(p1); - if ( - (isArray(p1.x) && (mappingData.x as number[]).indexOf(p1.x) === -1) || - (isNumber(p1.x) && mappingData.x !== p1.x) - ) { - label.x = (label.x + p1.x) / 2; - } - }); + // 处理漏斗图文本位置 + const shape = isArray(mappingData.shape) ? mappingData.shape[0] : mappingData.shape; + if (shape === 'funnel' || shape === 'pyramid') { + const nextPoints = get(mappingData, 'nextPoints'); + const points = get(mappingData, 'points'); + if (nextPoints) { + // 非漏斗图底部 + const point1 = coordinate.convert(points[1] as Point); + const point2 = coordinate.convert(nextPoints[1] as Point); + label.x = (point1.x + point2.x) / 2; + label.y = (point1.y + point2.y) / 2; + } else if (shape === 'pyramid') { + const point1 = coordinate.convert(points[1] as Point); + const point2 = coordinate.convert(points[2] as Point); + label.x = (point1.x + point2.x) / 2; + label.y = (point1.y + point2.y) / 2; + } } if (labelCfg.position) { @@ -284,44 +312,6 @@ export default class GeometryLabel { return labelsRenderer; } - private getItems(mapppingArray: MappingDatum[]): LabelItem[] { - const items = []; - const labelCfgs = this.getLabelCfgs(mapppingArray); - // 获取 label 相关的 x,y 的值,获取具体的 x, y,防止存在数组 - each(mapppingArray, (mappingData: MappingDatum, index: number) => { - const labelCfg = labelCfgs[index]; - if (!labelCfg) { - items.push(null); - return; - } - - const labelContent = !isArray(labelCfg.content) ? [labelCfg.content] : labelCfg.content; - labelCfg.content = labelContent; - const total = labelContent.length; - each(labelContent, (content, subIndex) => { - if (isNil(content) || content === '') { - items.push(null); - return; - } - - const item = { - ...labelCfg, - ...this.getLabelPoint(labelCfg, mappingData, subIndex), - }; - if (!item.textAlign) { - item.textAlign = this.getLabelAlign(item, subIndex, total); - } - - if (item.offset <= 0) { - item.labelLine = null; - } - - items.push(item); - }); - }); - return items; - } - private getLabelCfgs(mapppingArray: MappingDatum[]): LabelCfg[] { const geometry = this.geometry; const defaultLabelCfg = this.getDefaultLabelCfg(); From 8be4555d385b5efb307c7751ec791c59b8e4f1a1 Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 17 Mar 2020 13:08:39 +0800 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9E=81?= =?UTF-8?q?=E5=9D=90=E6=A0=87=E4=B8=8B=E7=9A=84=E7=AC=AC=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=20label=20=E5=AF=B9=E9=BD=90=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/geometry/label/polar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/geometry/label/polar.ts b/src/geometry/label/polar.ts index f4cf88b675..f44d71ab44 100644 --- a/src/geometry/label/polar.ts +++ b/src/geometry/label/polar.ts @@ -20,7 +20,7 @@ export default class PolarLabel extends GeometryLabel { const coordinate = this.getCoordinate(); let align; if (point.labelEmit) { - align = (point.angle <= Math.PI / 2 && point.angle > -Math.PI / 2) ? 'left' : 'right'; + align = (point.angle <= Math.PI / 2 && point.angle >= -Math.PI / 2) ? 'left' : 'right'; } else if (!coordinate.isTransposed) { align = 'center'; } else { From b392774585a43ab892fb88452b6e9d8403c78452 Mon Sep 17 00:00:00 2001 From: simaQ Date: Tue, 17 Mar 2020 13:10:40 +0800 Subject: [PATCH 15/16] =?UTF-8?q?test(label):=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=8F=8A=E6=B7=BB=E5=8A=A0=20label=20=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/geometry/label/base-spec.ts | 19 +- .../unit/geometry/label/chart/funnel-spec.ts | 171 ++++++++++++++++++ .../label/chart/polar-interval-spec.ts | 88 +++++++++ .../unit/geometry/label/chart/polygon-spec.ts | 90 +++++++++ tests/unit/geometry/label/labels-spec.ts | 4 +- tests/unit/geometry/label/pie-spec.ts | 43 ++++- 6 files changed, 399 insertions(+), 16 deletions(-) create mode 100644 tests/unit/geometry/label/chart/funnel-spec.ts create mode 100644 tests/unit/geometry/label/chart/polar-interval-spec.ts create mode 100644 tests/unit/geometry/label/chart/polygon-spec.ts diff --git a/tests/unit/geometry/label/base-spec.ts b/tests/unit/geometry/label/base-spec.ts index 899a246221..d11b9f665a 100644 --- a/tests/unit/geometry/label/base-spec.ts +++ b/tests/unit/geometry/label/base-spec.ts @@ -50,8 +50,14 @@ describe('GeometryLabel', () => { point.init(); const geometryLabel = new GeometryLabel(point); + const labelsContainer = point.labelsContainer; + + it('defaultLayout', () => { + expect(geometryLabel.defaultLayout).toBeUndefined(); + }); it('offset', () => { + // @ts-ignore const labelItems = geometryLabel.getLabelItems([ { x: 100, y: 10, _origin: { x: 100, y: 10, z: '1' } }, { x: 100, y: 20, _origin: { x: 100, y: 20, z: '2' } }, @@ -68,12 +74,15 @@ describe('GeometryLabel', () => { offsetX: 10, offsetY: 10, }); - const labelItems = geometryLabel.getLabelItems([ + geometryLabel.render([ { x: 100, y: 10, _origin: { x: 100, y: 10, z: '1' } }, { x: 100, y: 20, _origin: { x: 100, y: 20, z: '2' } }, - ]); - expect(labelItems[0].x).toBe(110); - expect(labelItems[0].y).toBe(0); + ], false); + + // @ts-ignore + const labelShape1 = labelsContainer.getChildren()[0].find(ele => ele.get('type') === 'text'); + expect(labelShape1.attr('x')).toBe(110); + expect(labelShape1.attr('y')).toBe(0); }); it('one point two labels', () => { @@ -92,6 +101,7 @@ describe('GeometryLabel', () => { } ); + // @ts-ignore const labelItems = geometryLabel.getLabelItems([ { x: 100, y: [10, 20], _origin: { x: 100, y: [10, 20], z: ['1', '2'] } }, { x: 100, y: [30, 40], _origin: { x: 100, y: [30, 40], z: ['3', '4'] } }, @@ -152,6 +162,7 @@ describe('GeometryLabel', () => { { x: 100, y: [30, 40], _origin: { x: 100, y: [30, 40], z: ['3', '4'] } }, ]); expect(labelItems.length).toBe(4); + expect(labelItems[0].style.fill).toBe('#FFFFFF'); }); it('stack points', () => { diff --git a/tests/unit/geometry/label/chart/funnel-spec.ts b/tests/unit/geometry/label/chart/funnel-spec.ts new file mode 100644 index 0000000000..2f82bde4b8 --- /dev/null +++ b/tests/unit/geometry/label/chart/funnel-spec.ts @@ -0,0 +1,171 @@ +import { flatten } from '@antv/util'; + +import { getCoordinate } from '@antv/coord'; +import { getScale } from '@antv/scale'; +import Interval from '../../../../../src/geometry/interval'; +import IntervalLabel from '../../../../../src/geometry/label/interval'; +import { getTheme } from '../../../../../src/theme/'; +import { createCanvas, createDiv } from '../../../../util/dom'; +import { createScale } from '../../../../util/scale'; + +const Theme = getTheme('default'); +const CartesianCoordinate = getCoordinate('rect'); +const IdentityScale = getScale('identity'); + +describe('Funnel chart label', () => { + const div = createDiv(); + const canvas = createCanvas({ + container: div, + width: 300, + height: 300, + }); + + const coord = new CartesianCoordinate({ + start: { x: 0, y: 150 }, + end: { x: 150, y: 0 }, + }); + coord.transpose().scale(1, -1); + + const pyramidScale = new IdentityScale({ + field: 'pyramid', + values: ['pyramid'], + range: [0, 1], + }); + const funnelScale = new IdentityScale({ + field: 'funnel', + values: ['funnel'], + range: [0, 1], + }); + + const data = [ + { action: '浏览网站', pv: 50000 }, + { action: '放入购物车', pv: 35000 }, + { action: '生成订单', pv: 25000 }, + { action: '支付订单', pv: 15000 }, + { action: '完成交易', pv: 8000 }, + ]; + + const scales = { + action: createScale('action', data), + pv: createScale('pv', data), + pyramid: pyramidScale, + funnel: funnelScale, + }; + + it('pyramid', () => { + const interval = new Interval({ + data, + scales, + container: canvas.addGroup(), + labelsContainer: canvas.addGroup(), + theme: Theme, + coordinate: coord, + }); + interval + .adjust('symmetric') + .position('action*pv') + .shape('pyramid') + .color('action', ['#0050B3', '#1890FF', '#40A9FF', '#69C0FF', '#BAE7FF']) + .label( + 'action*pv', + (action, pv) => { + return { + content: `${action} ${pv}`, + }; + }, + { + offset: 35, + labelLine: { + style: { + lineWidth: 1, + stroke: 'rgba(0, 0, 0, 0.15)', + }, + }, + } + ); + interval.init(); + interval.paint(); + + // 生成映射数据 + // @ts-ignore + const beforeMappingData = interval.beforeMappingData; + // @ts-ignore + const dataArray = interval.beforeMapping(beforeMappingData); + + let mappingArray = []; + for (const eachGroup of dataArray) { + // @ts-ignore + const mappingData = interval.mapping(eachGroup); + mappingArray.push(mappingData); + } + mappingArray = flatten(mappingArray); + + const gLabels = new IntervalLabel(interval); + const labelItems = gLabels.getLabelItems(mappingArray); + + expect(labelItems[0].x).toBe(173.75); + expect(labelItems[0].y).toBe(3.75); + expect(labelItems[2].x).toBe(140); + expect(labelItems[2].y).toBe(78.75); + expect(labelItems[4].x).toBe(116); + expect(labelItems[4].y).toBe(150); + }); + + it('funnel', () => { + const interval = new Interval({ + data, + scales, + container: canvas.addGroup(), + labelsContainer: canvas.addGroup(), + theme: Theme, + coordinate: coord, + }); + interval + .adjust('symmetric') + .position('action*pv') + .shape('funnel') + .color('action', ['#0050B3', '#1890FF', '#40A9FF', '#69C0FF', '#BAE7FF']) + .label( + 'action*pv', + (action, pv) => { + return { + content: `${action} ${pv}`, + }; + }, + { + offset: 35, + labelLine: { + style: { + lineWidth: 1, + stroke: 'rgba(0, 0, 0, 0.15)', + }, + }, + } + ); + interval.init(); + interval.paint(); + + // 生成映射数据 + // @ts-ignore + const beforeMappingData = interval.beforeMappingData; + // @ts-ignore + const dataArray = interval.beforeMapping(beforeMappingData); + + let mappingArray = []; + for (const eachGroup of dataArray) { + // @ts-ignore + const mappingData = interval.mapping(eachGroup); + mappingArray.push(mappingData); + } + mappingArray = flatten(mappingArray); + + const gLabels = new IntervalLabel(interval); + const labelItems = gLabels.getLabelItems(mappingArray); + expect(labelItems[0].x).toBe(173.75); + expect(labelItems[0].y).toBe(3.75); + expect(labelItems[2].x).toBe(140); + expect(labelItems[2].y).toBe(78.75); + expect(labelItems[4].x).toBe(122); + expect(labelItems[4].y).toBe(150); + }); +}); diff --git a/tests/unit/geometry/label/chart/polar-interval-spec.ts b/tests/unit/geometry/label/chart/polar-interval-spec.ts new file mode 100644 index 0000000000..2b1fe9bc74 --- /dev/null +++ b/tests/unit/geometry/label/chart/polar-interval-spec.ts @@ -0,0 +1,88 @@ +import { flatten } from '@antv/util'; + +import { getCoordinate } from '@antv/coord'; +import Interval from '../../../../../src/geometry/interval'; +import PolarLabel from '../../../../../src/geometry/label/polar'; +import { getTheme } from '../../../../../src/theme/'; +import { createCanvas, createDiv } from '../../../../util/dom'; +import { createScale } from '../../../../util/scale'; + +const Theme = getTheme('default'); +const PolarCoord = getCoordinate('polar'); + +describe('Interval label in Polar coordinate', () => { + const div = createDiv(); + const canvas = createCanvas({ + container: div, + width: 200, + height: 200, + }); + + const coord = new PolarCoord({ + start: { x: 0, y: 100 }, + end: { x: 100, y: 0 }, + }); + + const data = [ + { year: '1951 年', sales: 38 }, + { year: '1952 年', sales: 52 }, + { year: '1956 年', sales: 61 }, + { year: '1957 年', sales: 145 }, + { year: '1958 年', sales: 48 }, + { year: '1959 年', sales: 38 }, + { year: '1960 年', sales: 38 }, + { year: '1962 年', sales: 38 }, + ]; + const scaleDefs = { + sales: { + nice: true, + }, + }; + + const scales = { + year: createScale('year', data, scaleDefs), + sales: createScale('sales', data, scaleDefs), + }; + + const interval = new Interval({ + data, + scales, + container: canvas.addGroup(), + labelsContainer: canvas.addGroup(), + theme: Theme, + coordinate: coord, + scaleDefs, + }); + interval + .position('year*sales') + .label('sales', { + labelEmit: true, + }); + interval.init(); + interval.paint(); + + // 生成映射数据 + // @ts-ignore + const beforeMappingData = interval.beforeMappingData; + // @ts-ignore + const dataArray = interval.beforeMapping(beforeMappingData); + + let mappingArray = []; + for (const eachGroup of dataArray) { + // @ts-ignore + const mappingData = interval.mapping(eachGroup); + mappingArray.push(mappingData); + } + mappingArray = flatten(mappingArray); + + it('labels', () => { + const gLabels = new PolarLabel(interval); + const labelItems = gLabels.getLabelItems(mappingArray); + + expect(labelItems.length).toBe(data.length); + expect(labelItems[0].textAlign).toBe('left'); + expect(labelItems[0].angle).toBeCloseTo(-1.5707963267948966); + expect(labelItems[3].angle).toBeCloseTo(1.1219973762820692); + expect(labelItems[5].angle).toBeCloseTo(2.9171931783333793); + }); +}); diff --git a/tests/unit/geometry/label/chart/polygon-spec.ts b/tests/unit/geometry/label/chart/polygon-spec.ts new file mode 100644 index 0000000000..4dd48f4403 --- /dev/null +++ b/tests/unit/geometry/label/chart/polygon-spec.ts @@ -0,0 +1,90 @@ +import { flatten } from '@antv/util'; + +import { getCoordinate } from '@antv/coord'; +import IntervalLabel from '../../../../../src/geometry/label/interval'; +import Polygon from '../../../../../src/geometry/polygon'; +import { getTheme } from '../../../../../src/theme/'; +import { createCanvas, createDiv, removeDom } from '../../../../util/dom'; + +import 'jest-extended'; +import { createScale } from '../../../../util/scale'; + +const CartesianCoordinate = getCoordinate('rect'); +const Theme = getTheme('default'); + +describe('Polygon', () => { + const div = createDiv(); + const canvas = createCanvas({ + container: div, + width: 300, + height: 300, + }); + const rectCoord = new CartesianCoordinate({ + start: { x: 0, y: 300 }, + end: { x: 300, y: 0 }, + }); + const data = [ + { city: '杭州', sale: 100, category: '电脑' }, + { city: '广州', sale: 30, category: '电脑' }, + { city: '上海', sale: 200, category: '鼠标' }, + { city: '呼和浩特', sale: 10, category: '鼠标' }, + ]; + + const scales = { + city: createScale('city', data, { + city: { + range: [0.125, 0.875], + }, + }), + sale: createScale('sale', data), + category: createScale('category', data, { + category: { + range: [0.25, 0.75], + }, + }), + }; + + const polygon = new Polygon({ + data, + scales, + container: canvas.addGroup(), + labelsContainer: canvas.addGroup(), + theme: Theme, + coordinate: rectCoord, + }); + + polygon.position('city*category').color('sale').label('sale', { + offset: 0, + }); + polygon.init(); + polygon.paint(); + // 生成映射数据 + // @ts-ignore + const beforeMappingData = polygon.beforeMappingData; + // @ts-ignore + const dataArray = polygon.beforeMapping(beforeMappingData); + + let mappingArray = []; + for (const eachGroup of dataArray) { + // @ts-ignore + const mappingData = polygon.mapping(eachGroup); + mappingArray.push(mappingData); + } + mappingArray = flatten(mappingArray); + + it('labels', () => { + const gLabels = new IntervalLabel(polygon); + const labelItems = gLabels.getLabelItems(mappingArray); + expect(labelItems[0].x).toBe(37.5); + expect(labelItems[0].y).toBe(225); + + expect(labelItems[1].x).toBe(112.5); + expect(labelItems[1].y).toBe(225); + + expect(labelItems[2].x).toBe(187.5); + expect(labelItems[2].y).toBe(75); + + expect(labelItems[3].x).toBe(262.5); + expect(labelItems[3].y).toBe(75); + }); +}); diff --git a/tests/unit/geometry/label/labels-spec.ts b/tests/unit/geometry/label/labels-spec.ts index 192fd56378..59b114678c 100644 --- a/tests/unit/geometry/label/labels-spec.ts +++ b/tests/unit/geometry/label/labels-spec.ts @@ -259,8 +259,8 @@ describe('LabelsRenderer', () => { const labelsContainer = interval.labelsContainer; expect(labelsContainer.getCount()).toBe(2); - const femaleLabel = labelsContainer.getChildren()[0]; - const maleLabel = labelsContainer.getChildren()[1]; + const femaleLabel = labelsContainer.findById('1-女'); + const maleLabel = labelsContainer.findById('1-男'); // @ts-ignore expect(femaleLabel.getFirst().get('type')).toBe('text'); // @ts-ignore diff --git a/tests/unit/geometry/label/pie-spec.ts b/tests/unit/geometry/label/pie-spec.ts index 6695db9967..9ba9ac3877 100644 --- a/tests/unit/geometry/label/pie-spec.ts +++ b/tests/unit/geometry/label/pie-spec.ts @@ -79,6 +79,10 @@ describe('pie labels', () => { let items; + it('defaultLayout', () => { + expect(gLabels.defaultLayout).toBe('distribute'); + }); + it('get items', () => { items = gLabels.getLabelItems(points); expect(items.length).toBe(points.length); @@ -177,23 +181,42 @@ describe('pie labels', () => { const gLabels = new PieLabel(pointGeom); - it('points', () => { + it('render', () => { + gLabels.render(points, false); + const items = gLabels.getLabelItems(points); - expect(items.length).toBe(points.length); - expect(items[0].x).toBe(230); + + const labels = gLabels.labelsRenderer.container.getChildren(); + expect(labels.length).toBe(points.length); + + // @ts-ignore + const labelText0 = labels[0].find(ele => ele.get('type') === 'text'); + expect(labelText0.attr('x')).toBe(items[0].x + 10); + expect(labelText0.attr('y')).toBe(items[0].y - 10); + // @ts-ignore + expect(labels[0].getCount()).toBe(1); expect(items[0].labelLine).toBe(false); - expect(isNumberEqual(items[0].y, 48.03847577293368 - 10)).toBeTruthy(); - expect(items[1].x).toBe(200); + // @ts-ignore + const labelText1 = labels[1].find(ele => ele.get('type') === 'text'); + expect(labelText1.attr('x')).toBe(items[1].x + 10); + expect(labelText1.attr('y')).toBe(items[1].y - 10); + // @ts-ignore + expect(labels[1].getCount()).toBe(1); expect(items[1].labelLine).toBe(false); - expect(isNumberEqual(items[1].y, 100 - 10)).toBeTruthy(); - expect(items[2].x).toBe(230.00000000000006); + // @ts-ignore + const labelText2 = labels[2].find(ele => ele.get('type') === 'text'); + expect(labelText2.attr('x')).toBe(items[2].x + 10); + expect(labelText2.attr('y')).toBe(items[2].y - 10); + // @ts-ignore + expect(labels[2].getCount()).toBe(1); expect(items[2].labelLine).toBe(false); - expect(isNumberEqual(items[2].y, 151.96152422706632 - 10)).toBeTruthy(); - expect(items[5].x).toBe(290); - expect(isNumberEqual(items[5].y, 151.96152422706632 - 10)).toBeTruthy(); + // @ts-ignore + const labelText5 = labels[5].find(ele => ele.get('type') === 'text'); + expect(labelText5.attr('x')).toBe(items[5].x + 10); + expect(labelText5.attr('y')).toBe(items[5].y - 10); }); }); }); From ac4f3f42a6aae9779e78d6b977de1e24cc01f634 Mon Sep 17 00:00:00 2001 From: simaQ Date: Thu, 19 Mar 2020 10:57:13 +0800 Subject: [PATCH 16/16] =?UTF-8?q?fix(label):=20=E7=A7=BB=E9=99=A4=20=5Fnam?= =?UTF-8?q?e=20=E5=B1=9E=E6=80=A7=EF=BC=8C=E7=9B=B4=E6=8E=A5=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20type=20=E7=B1=BB=E5=9E=8B=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=20labelShape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/component/labels.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/component/labels.ts b/src/component/labels.ts index f4179ab683..816306c8c9 100644 --- a/src/component/labels.ts +++ b/src/component/labels.ts @@ -188,7 +188,6 @@ export default class Labels { translate(content, x, y); // 将 label 平移至 x, y 指定的位置 labelShape = content; - content.set('_name', 'labelContent'); labelGroup.add(content); } else { labelShape = labelGroup.addShape('text', { @@ -201,7 +200,6 @@ export default class Labels { ...cfg.style, }, ...shapeAppendCfg, - _name: 'labelContent', }); } @@ -282,12 +280,14 @@ export default class Labels { const id = item.id; const labelGroup = this.shapesMap[id]; if (!labelGroup.destroyed) { - const labelShape = labelGroup.find(ele => ele.get('_name') === 'labelContent'); - if (item.offsetX) { - labelShape.attr('x', labelShape.attr('x') + item.offsetX); - } - if (item.offsetY) { - labelShape.attr('y', labelShape.attr('y') + item.offsetY); + const labelShape = labelGroup.find(ele => ele.get('type') === 'text'); + if (labelShape) { + if (item.offsetX) { + labelShape.attr('x', labelShape.attr('x') + item.offsetX); + } + if (item.offsetY) { + labelShape.attr('y', labelShape.attr('y') + item.offsetY); + } } } }