diff --git a/__tests__/unit/components/marker-point-spec.ts b/__tests__/unit/components/marker-point-spec.ts new file mode 100644 index 0000000000..114419e5d0 --- /dev/null +++ b/__tests__/unit/components/marker-point-spec.ts @@ -0,0 +1,197 @@ +import { createDiv } from '../../utils/dom'; +import { Line } from '../../../src'; +import MarkerPoint from '../../../src/components/marker-point'; + +const data = [ + { + date: '2018/8/12', + value: 5, + }, + { + date: '2018/8/12', + description: 'register', + value: 5, + }, + { + date: '2018/8/12', + value: 5, + }, + { + date: '2018/8/13', + value: 5, + }, +]; + +describe('Marker Point', () => { + const div = createDiv('canvas'); + + const line = new Line(div, { + width: 400, + height: 400, + xField: 'date', + yField: 'value', + padding: [0, 0, 0, 0], + data, + point: { + visible: true, + }, + }); + + line.render(); + + it('normal', () => { + const markerPoint = new MarkerPoint({ + view: line.getLayer().view, + data: [data[0], data[1]], + }); + + // @ts-ignore + const points = markerPoint.points; + expect(markerPoint.container.getChildren().length).toBe(2); + // @ts-ignore + expect(points.length).toBe(2); + // @ts-ignore + expect(markerPoint.labels.length).toBe(0); + + const shapes = line.getLayer().view.geometries[1].getShapes(); + expect(shapes[0].getBBox().minX + shapes[0].getBBox().width / 2).toBe( + points[0].getBBox().minX + points[0].getBBox().width / 2 + ); + }); + + it('marker: custom image symbol', () => { + const markerPoint = new MarkerPoint({ + view: line.getLayer().view, + data: [data[0], data[1]], + }); + + // @ts-ignore + const points = markerPoint.points; + expect(points[0].get('type')).not.toBe('image'); + + const markerPoint1 = new MarkerPoint({ + view: line.getLayer().view, + data: [data[0], data[1]], + symbol: 'image://imgaeUrl', + }); + // @ts-ignore + expect(markerPoint1.points[0].get('type')).toBe('image'); + }); + + it('marker, offsetX & offsetY', () => { + const markerPoint = new MarkerPoint({ + view: line.getLayer().view, + data: [data[0], data[1]], + offsetX: 10, + offsetY: -5, + }); + + // @ts-ignore + const points = markerPoint.points; + + const shapes = line.getLayer().view.geometries[1].getShapes(); + const shapeCenterPos = { + x: shapes[0].getBBox().minX + shapes[0].getBBox().width / 2, + y: shapes[0].getBBox().minY + shapes[0].getBBox().height / 2, + }; + const pointCenterPos = { + x: points[0].getBBox().minX + points[0].getBBox().width / 2, + y: points[0].getBBox().minY + points[0].getBBox().height / 2, + }; + expect(shapeCenterPos.x).not.toBe(pointCenterPos.x); + expect(shapeCenterPos.x + 10 /** offsetX */).toBe(pointCenterPos.x); + expect(shapeCenterPos.y - 5 /** offsetX */).toBe(pointCenterPos.y); + }); + + it('marker label & label style', () => { + const markerPoint = new MarkerPoint({ + view: line.getLayer().view, + data: [data[0], data[1]], + label: { + visible: true, + field: 'description', + style: { + fill: 'red', + }, + }, + }); + + // @ts-ignore + const points = markerPoint.points; + // @ts-ignore + const labels = markerPoint.labels; + expect(labels.length).toBe(2); + expect(labels[1].attr('text')).toBe(data[1]['description']); + expect(labels[1].attr('fill')).toBe('red'); + expect(points[0].getBBox().y).toBeGreaterThan(labels[0].getBBox().y); + }); + + it('label position & offsetX & offsetY', () => { + const markerPoint = new MarkerPoint({ + view: line.getLayer().view, + data: [data[0], data[1]], + label: { + visible: true, + field: 'description', + position: 'bottom', + offsetX: 10, + offsetY: 5, + }, + }); + // @ts-ignore + const points = markerPoint.points; + // @ts-ignore + const labels = markerPoint.labels; + const labelBBox = labels[0].getBBox(); + expect(points[0].getBBox().y).toBeLessThan(labelBBox.y); + expect(points[0].attr('x') + 10).toBe(labelBBox.minX + labelBBox.width / 2); + expect(points[0].attr('y') + 5).toBe(labelBBox.minY); + }); + + it('interaction & events', (done) => { + let clicked = false; + const markerPoint = new MarkerPoint({ + view: line.getLayer().view, + data: [data[0], data[1]], + label: { + visible: true, + field: 'description', + }, + events: { + click: () => (clicked = true), + }, + style: { + normal: { + stroke: 'transparent', + }, + selected: { + stroke: 'blue', + fill: 'red', + }, + active: { + stroke: 'yellow', + fill: 'red', + }, + }, + }); + // @ts-ignore + const points = markerPoint.points; + setTimeout(() => { + // @ts-ignore + markerPoint.container.emit(`${markerPoint.name}:mouseenter`, { + target: points[1], + }); + expect(points[1].attr('stroke')).toBe('yellow'); + expect(points[1].attr('fill')).toBe('red'); + // @ts-ignore + markerPoint.container.emit(`${markerPoint.name}:click`, { + target: points[0], + }); + expect(clicked).toBe(true); + expect(points[0].attr('stroke')).toBe('blue'); + expect(points[0].attr('fill')).toBe('red'); + expect(points[1].attr('stroke')).toBe('transparent'); + done(); + }); + }); +}); diff --git a/__tests__/unit/plots/line/line-with-markerPoint-spec.ts b/__tests__/unit/plots/line/line-with-markerPoint-spec.ts new file mode 100644 index 0000000000..2bbe595718 --- /dev/null +++ b/__tests__/unit/plots/line/line-with-markerPoint-spec.ts @@ -0,0 +1,111 @@ +import { Line } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; +import { income as data } from '../../../data/income'; +import MarkerPoint from '../../../../src/components/marker-point'; +import { IShape } from '@antv/g2/lib/dependents'; + +describe('Line plot with marker-point', () => { + const div = createDiv(); + const linePlot = new Line(div, { + width: 600, + height: 600, + data, + xField: 'time', + yField: 'rate', + markerPoints: [ + { + visible: true, + data: [{ time: '2013-06-13' }], + }, + ], + }); + linePlot.render(); + + it('normal', () => { + const layer = linePlot.getLayer(); + // @ts-ignore + const markerPoints: MarkerPoint[] = layer.markerPoints; + expect(markerPoints.length).toBe(1); + // @ts-ignore + expect(markerPoints[0].points.length).toBe(1); + // @ts-ignore + expect(markerPoints[0].labels.length).toBe(0); + }); + + it('with 2 markerPoints component', () => { + linePlot.updateConfig({ + markerPoints: [ + { + visible: true, + data: [{ time: '2013-06-13' }], + }, + { + visible: true, + data: [{ time: '2013-06-16' }], + }, + ], + }); + linePlot.render(); + const layer = linePlot.getLayer(); + // @ts-ignore + const markerPoints: MarkerPoint[] = layer.markerPoints; + expect(markerPoints.length).toBe(2); + }); + + it('custom markerPoints style', () => { + linePlot.updateConfig({ + markerPoints: [ + { + visible: true, + data: [{ time: '2013-06-13' }], + style: { + normal: { + fill: 'red', + stroke: '#000', + lineWidth: 1, + }, + }, + }, + ], + }); + linePlot.render(); + const layer = linePlot.getLayer(); + // @ts-ignore + const pointShapes: IShape[] = layer.markerPoints[0].points; + expect(pointShapes[0].attr('fill')).toBe('red'); + expect(pointShapes[0].attr('stroke')).toBe('#000'); + expect(pointShapes[0].attr('lineWidth')).toBe(1); + }); + + it('markerPoints with label', () => { + linePlot.updateConfig({ + markerPoints: [ + { + visible: true, + data: [{ time: '2013-06-13' }, { time: '2013-06-18' }], + style: { + normal: { + fill: 'red', + stroke: '#000', + lineWidth: 1, + }, + }, + label: { + visible: true, + formatter: () => 'hello', + style: { + fill: 'red', + }, + }, + }, + ], + }); + linePlot.render(); + const layer = linePlot.getLayer(); + // @ts-ignore + const labelShapes: IShape[] = layer.markerPoints[0].labels; + expect(labelShapes.length).toBe(2); + expect(labelShapes[0].attr('fill')).toBe('red'); + expect(labelShapes[0].attr('text')).toBe('hello'); + }); +}); diff --git a/docs/manual/plots/pie.en.md b/docs/manual/plots/pie.en.md index f18ffbca2b..3f3df40a1d 100644 --- a/docs/manual/plots/pie.en.md +++ b/docs/manual/plots/pie.en.md @@ -289,7 +289,6 @@ color:(d)=>{ | position | string | 位置,支持三种配置:
'left', 'middle', 'right' | left | | style | object | 样式:
- fontSize: number 文字大小
- fill: string 文字颜色
- stroke: string  描边颜色
- lineWidth: number 描边粗细
- lineDash: number 虚线描边
- opacity: number 透明度
- fillOpacity: number 填充透明度
- strokeOpacity: number 描边透明度
| `{ fontSize: 12, fill: 'grey' }` | - ### legend **可选**, *object* @@ -332,6 +331,8 @@ color:(d)=>{ | visible | boolean | 是否显示 | false | | type | string | label的类型
- inner label显示于扇形切片内
- outer label显示于饼外
- outer-center label呈圆形排布于饼外
- spider 蜘蛛布局label| inner | | autoRotate | boolean | 是否自动旋转 | false | +| adjustPosition | boolean | 是否自动调整label位置(当label发生遮挡的时候,会自动进行调整) | true | +| allowOverlap | boolean | 是否自动遮挡(当label发生遮挡的时候,会自动隐藏) | false | | formatter | function | 对文本标签内容进行格式化 | - | | offsetX | number | 在 label 位置的基础上再往 x 方向的偏移量 | - | | offsetY | number | 在 label 位置的基础上再往 y 方向的偏移量 | - | diff --git a/docs/manual/plots/pie.zh.md b/docs/manual/plots/pie.zh.md index d7e6763b7a..3f3df40a1d 100644 --- a/docs/manual/plots/pie.zh.md +++ b/docs/manual/plots/pie.zh.md @@ -331,6 +331,8 @@ color:(d)=>{ | visible | boolean | 是否显示 | false | | type | string | label的类型
- inner label显示于扇形切片内
- outer label显示于饼外
- outer-center label呈圆形排布于饼外
- spider 蜘蛛布局label| inner | | autoRotate | boolean | 是否自动旋转 | false | +| adjustPosition | boolean | 是否自动调整label位置(当label发生遮挡的时候,会自动进行调整) | true | +| allowOverlap | boolean | 是否自动遮挡(当label发生遮挡的时候,会自动隐藏) | false | | formatter | function | 对文本标签内容进行格式化 | - | | offsetX | number | 在 label 位置的基础上再往 x 方向的偏移量 | - | | offsetY | number | 在 label 位置的基础上再往 y 方向的偏移量 | - | diff --git a/examples/line/marker/API.en.md b/examples/line/marker/API.en.md new file mode 100644 index 0000000000..bc82096aa2 --- /dev/null +++ b/examples/line/marker/API.en.md @@ -0,0 +1,136 @@ +--- +title: API +--- + +说明: **required** 标签代表组件的必选配置项,**optional** 标签代表组件的可选配置项。 + +- `style: object`    标注点样式。
+ + - `fill: string`    标注点颜色
+ - `opacity: number`  标注点颜色透明度
+ - `stroke: string`    标注点描边色
+ - `lineWidth: number`    标注点描边粗细 + +## 快速开始 + +[DEMOS](https://g2plot.antv.vision/zh/examples/general/markerPoint) + +配置标注点示例代码: + +```js +{ + markerPoints: [ + { + visible: true, + shape: 'circle', + data: [], + style: { + /** 正常样式 **/ + normal: {}, + /** 激活样式 **/ + active: {}, + /** 选中样式 **/ + selected: {}, + }, + label: { + visible: true, + position: 'top', + style: {}, + }, + }, + ], +} +``` + +## symbol + +**optional** string | Function, 默认: `circle` + +标注点图形类型 + +1. string 类型。 + +- 内置类型,可参见 G2 支持的`symbol`类型,包括: `hexagon`, `bowtie`, `cross`, `tick`, `plus`, `hyphen`, `line` +- image类型,通过`iamge://url`的方式,指定标注点为具体的图片,url为图片地址 + +2. Function 类型,可以自定义 symbol 绘制,如下: + +```typwscript +sumbol: (x: number, y: number, r: number) => { + return [ + ['M', x - r, y - r], + ['L', x + r, y + r], + ['L', x + r, y - r], + ['L', x - r, y + r], + ['L', x - r, y - r], + ['Z'], + ]; +} +``` + +## size + +**optional** number, 默认: 6 + +symbol 的大小 + +## offsetX + +**optional** number, 默认: 0 + +标注点坐标 x 方向偏移 + +## offsetY + +**optional** number, 默认: 0 + +标注点坐标 y 方向偏移 + +## data + +**required** array + +标注点的数据数组,每个数据项是一个对象 + +> 注意,标注点的数据数组是图表 data 的子集 + +示例: + +```typescript +data: [ + // 匹配所有数据项为 3 的数据点 + { value: 3 }, + // 匹配 日期为 2019-10-01,且数值为 3 的数据点 + { date: '2019-10-01', value: 3 }, +]; +``` + +## label + +**optional** object + +- `visible: boolean` 标注点标签是否可见 +- `formatter: function` 标签格式化 +- `field: string` 标注点映射的数据字段,用于标注点标签 +- `position: string` 标注点标签位置,`top` | `bottom` +- `offsetX: number` x 方向偏移 +- `offsetY: number` y 方向偏移 +- `style: object` 样式 + +## style + +**optional** object + +- `normal` 正常样式 +- `active` 激活样式 +- `selected` 选中样式 + +## events + +**optional** object + +标注点事件 + +- `mouseenter` 鼠标移入事件 +- `mouseleave` 鼠标移出事件 +- `click` 鼠标 click 事件 diff --git a/examples/line/marker/API.zh.md b/examples/line/marker/API.zh.md new file mode 100644 index 0000000000..bc82096aa2 --- /dev/null +++ b/examples/line/marker/API.zh.md @@ -0,0 +1,136 @@ +--- +title: API +--- + +说明: **required** 标签代表组件的必选配置项,**optional** 标签代表组件的可选配置项。 + +- `style: object`    标注点样式。
+ + - `fill: string`    标注点颜色
+ - `opacity: number`  标注点颜色透明度
+ - `stroke: string`    标注点描边色
+ - `lineWidth: number`    标注点描边粗细 + +## 快速开始 + +[DEMOS](https://g2plot.antv.vision/zh/examples/general/markerPoint) + +配置标注点示例代码: + +```js +{ + markerPoints: [ + { + visible: true, + shape: 'circle', + data: [], + style: { + /** 正常样式 **/ + normal: {}, + /** 激活样式 **/ + active: {}, + /** 选中样式 **/ + selected: {}, + }, + label: { + visible: true, + position: 'top', + style: {}, + }, + }, + ], +} +``` + +## symbol + +**optional** string | Function, 默认: `circle` + +标注点图形类型 + +1. string 类型。 + +- 内置类型,可参见 G2 支持的`symbol`类型,包括: `hexagon`, `bowtie`, `cross`, `tick`, `plus`, `hyphen`, `line` +- image类型,通过`iamge://url`的方式,指定标注点为具体的图片,url为图片地址 + +2. Function 类型,可以自定义 symbol 绘制,如下: + +```typwscript +sumbol: (x: number, y: number, r: number) => { + return [ + ['M', x - r, y - r], + ['L', x + r, y + r], + ['L', x + r, y - r], + ['L', x - r, y + r], + ['L', x - r, y - r], + ['Z'], + ]; +} +``` + +## size + +**optional** number, 默认: 6 + +symbol 的大小 + +## offsetX + +**optional** number, 默认: 0 + +标注点坐标 x 方向偏移 + +## offsetY + +**optional** number, 默认: 0 + +标注点坐标 y 方向偏移 + +## data + +**required** array + +标注点的数据数组,每个数据项是一个对象 + +> 注意,标注点的数据数组是图表 data 的子集 + +示例: + +```typescript +data: [ + // 匹配所有数据项为 3 的数据点 + { value: 3 }, + // 匹配 日期为 2019-10-01,且数值为 3 的数据点 + { date: '2019-10-01', value: 3 }, +]; +``` + +## label + +**optional** object + +- `visible: boolean` 标注点标签是否可见 +- `formatter: function` 标签格式化 +- `field: string` 标注点映射的数据字段,用于标注点标签 +- `position: string` 标注点标签位置,`top` | `bottom` +- `offsetX: number` x 方向偏移 +- `offsetY: number` y 方向偏移 +- `style: object` 样式 + +## style + +**optional** object + +- `normal` 正常样式 +- `active` 激活样式 +- `selected` 选中样式 + +## events + +**optional** object + +标注点事件 + +- `mouseenter` 鼠标移入事件 +- `mouseleave` 鼠标移出事件 +- `click` 鼠标 click 事件 diff --git a/examples/line/marker/demo/animate.js b/examples/line/marker/demo/animate.js new file mode 100644 index 0000000000..122f837d68 --- /dev/null +++ b/examples/line/marker/demo/animate.js @@ -0,0 +1,67 @@ +import { Line } from '@antv/g2plot'; + +const data = [ + { date: '2019-01-01', value: 3 }, + { date: '2019-02-01', value: 4 }, + { date: '2019-03-01', value: 3.5 }, + { date: '2019-04-01', value: 5 }, + { date: '2019-05-01', value: 4.9 }, + { date: '2019-06-01', value: 6 }, + { date: '2019-07-01', value: 7 }, + { date: '2019-08-01', value: 9 }, + { date: '2019-09-01', value: 3 }, + { date: '2019-10-01', value: 16 }, + { date: '2019-11-01', value: 6 }, + { date: '2019-12-01', value: 8 }, +]; + +const maxValue = Math.max.apply( + [], + data.map((d) => d.value) +); + +const linePlot = new Line(document.getElementById('container'), { + title: { + visible: true, + text: '标注最大值(带动画)', + }, + description: { + visible: true, + text: '可通过 animation 配置标注点的动画', + }, + forceFit: true, + padding: 'auto', + data, + xField: 'date', + yField: 'value', + yAxis: { + nice: true, + }, + label: { + visible: false, + }, + markerPoints: [ + { + visible: true, + data: [{ value: maxValue }], + label: { + visible: true, + formatter: () => '最大值', + }, + style: { + normal: { fill: 'rgba(255, 0, 0, 0.65)' }, + }, + animation: { + endState: { size: 4, opacity: 0.3 }, + animateCfg: { + duration: 1500, + easing: 'easeLinear', + repeat: true, + delay: 1200, + }, + }, + }, + ], +}); + +linePlot.render(); diff --git a/examples/line/marker/demo/basic.js b/examples/line/marker/demo/basic.js new file mode 100644 index 0000000000..f50489e1f3 --- /dev/null +++ b/examples/line/marker/demo/basic.js @@ -0,0 +1,50 @@ +import { Line } from '@antv/g2plot'; + +const data = [ + { date: '2019-01-01', value: 3 }, + { date: '2019-02-01', value: 4 }, + { date: '2019-03-01', value: 3.5 }, + { date: '2019-04-01', value: 5 }, + { date: '2019-05-01', value: 4.9, festival: '劳动节' }, + { date: '2019-06-01', value: 6 }, + { date: '2019-07-01', value: 7 }, + { date: '2019-08-01', value: 9 }, + { date: '2019-09-01', value: 3 }, + { date: '2019-10-01', value: 13, festival: '国庆节' }, + { date: '2019-11-01', value: 6 }, + { date: '2019-12-01', value: 23 }, +]; + +const linePlot = new Line(document.getElementById('container'), { + title: { + visible: true, + text: '带标注点的折线图', + }, + description: { + visible: true, + text: '在折线图上标注重点的数据,如节假日等', + }, + forceFit: true, + padding: 'auto', + data, + xField: 'date', + yField: 'value', + yAxis: { + nice: true, + }, + label: { + visible: false, + }, + markerPoints: [ + { + visible: true, + data: [{ date: '2019-05-01', value: 4.9 }, { date: '2019-10-01' }], + label: { + visible: true, + field: 'festival', + }, + }, + ], +}); + +linePlot.render(); diff --git a/examples/line/marker/demo/custom-image.js b/examples/line/marker/demo/custom-image.js new file mode 100644 index 0000000000..bf617e0d3d --- /dev/null +++ b/examples/line/marker/demo/custom-image.js @@ -0,0 +1,59 @@ +import { Line } from '@antv/g2plot'; + +const data = [ + { date: '2019-01-01', value: 3 }, + { date: '2019-02-01', value: 4 }, + { date: '2019-03-01', value: 3.5 }, + { date: '2019-04-01', value: 5 }, + { date: '2019-05-01', value: 4.9 }, + { date: '2019-06-01', value: 6 }, + { date: '2019-07-01', value: 7 }, + { date: '2019-08-01', value: 9 }, + { date: '2019-09-01', value: 7 }, + { date: '2019-10-01', value: 13 }, + { date: '2019-11-01', value: 13 }, + { date: '2019-12-01', value: 13 }, +]; + +const linePlot = new Line(document.getElementById('container'), { + title: { + visible: true, + text: '使用 image 定义标注点', + }, + description: { + visible: true, + text: '除了内置 symbol,还可以通过 "image://url" 设置为图片,其中 url 为图片的链接', + }, + forceFit: true, + padding: 'auto', + data, + xField: 'date', + yField: 'value', + yAxis: { + nice: true, + }, + label: { + visible: false, + }, + markerPoints: [ + { + visible: true, + data: [{ date: '2019-09-01' }], + size: 20, + symbol: 'image://https://gw.alipayobjects.com/mdn/rms_a30de3/afts/img/A*66RtR4cXNWoAAAAAAAAAAABkARQnAQ', + label: { + visible: true, + position: 'bottom', + offsetY: 8, + }, + style: { + // 关闭动态样式 + normal: { lineWidth: 0, fill: 'transparent' }, + active: { lineWidth: 0, fill: 'transparent' }, + selected: { lineWidth: 0, fill: 'transparent' }, + }, + }, + ], +}); + +linePlot.render(); diff --git a/examples/line/marker/demo/custom-symbol.js b/examples/line/marker/demo/custom-symbol.js new file mode 100644 index 0000000000..b8c06829c4 --- /dev/null +++ b/examples/line/marker/demo/custom-symbol.js @@ -0,0 +1,88 @@ +import { Line } from '@antv/g2plot'; + +const data = [ + { date: '2019-01-01', value: 3 }, + { date: '2019-02-01', value: 4 }, + { date: '2019-03-01', value: 3.5 }, + { date: '2019-04-01', value: 5 }, + { date: '2019-05-01', value: 4.9 }, + { date: '2019-06-01', value: 6 }, + { date: '2019-07-01', value: 7 }, + { date: '2019-08-01', value: 9 }, + { date: '2019-09-01', value: 3 }, + { date: '2019-10-01', value: 23 }, + { date: '2019-11-01', value: 6 }, + { date: '2019-12-01', value: 8 }, +]; + +const generateStarPoints = (x, y, r) => { + const points = []; + for (let i = 0; i < 5; i++) { + const x1 = r * Math.cos(((54 + i * 72) / 180) * Math.PI) + x; + const y1 = r * Math.sin(((54 + i * 72) / 180) * Math.PI) + y; + // 内接点 + const x2 = r * Math.cos(((18 + i * 72) / 180) * Math.PI) * 0.5 + x; + const y2 = r * Math.sin(((18 + i * 72) / 180) * Math.PI) * 0.5 + y; + + points.push([x2, y2]); + points.push([x1, y1]); + } + + return points; +}; + +const linePlot = new Line(document.getElementById('container'), { + title: { + visible: true, + text: '自定义标注点 symbol', + }, + description: { + visible: true, + text: '内置 symbol 类型有:cross, hexagon, bowtie, tick, plus, hyphen, line', + }, + forceFit: true, + padding: 'auto', + data, + xField: 'date', + yField: 'value', + yAxis: { + nice: true, + }, + label: { + visible: false, + }, + markerPoints: [ + { + visible: true, + data: [{ value: 23 }], + label: { + visible: true, + formatter: () => '最大值', + }, + size: 12, + style: { + normal: { fill: 'rgba(255, 255, 0, 0.85)', stroke: 'rgba(0,0,0,0.65)', lineWidth: 1 }, + }, + symbol: (x, y, r) => { + const points = generateStarPoints(x, y, r); + const path = []; + points.forEach((point, idx) => { + path.push([idx === 0 ? 'M' : 'L', point[0], point[1]]); + }); + path.push(['Z']); + return path; + }, + animation: { + endState: { size: 4, opacity: 0.3 }, + animateCfg: { + duration: 1500, + easing: 'easeLinear', + repeat: true, + delay: 1200, + }, + }, + }, + ], +}); + +linePlot.render(); diff --git a/examples/line/marker/demo/meta.json b/examples/line/marker/demo/meta.json new file mode 100644 index 0000000000..8936069b16 --- /dev/null +++ b/examples/line/marker/demo/meta.json @@ -0,0 +1,33 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "basic.js", + "title": "带标注点的折线图", + "screenshot": "https://gw.alipayobjects.com/mdn/rms_d314dd/afts/img/A*b9T_Q5xpZgYAAAAAAAAAAABkARQnAQ" + }, + { + "filename": "multi-markerpoint.js", + "title": "多种类型标注点", + "screenshot": "https://gw.alipayobjects.com/mdn/rms_d314dd/afts/img/A*-WEbS6l8Xk0AAAAAAAAAAABkARQnAQ" + }, + { + "filename": "animate.js", + "title": "带动画的标注点", + "screenshot": "https://gw.alipayobjects.com/mdn/rms_d314dd/afts/img/A*WKioSL5x4rgAAAAAAAAAAABkARQnAQ" + }, + { + "filename": "custom-symbol.js", + "title": "自定义标注点", + "screenshot": "https://gw.alipayobjects.com/mdn/rms_d314dd/afts/img/A*RekySYi5EmoAAAAAAAAAAABkARQnAQ" + }, + { + "filename": "custom-image.js", + "title": "使用 image 定义标注点", + "screenshot": "https://gw.alipayobjects.com/mdn/rms_d314dd/afts/img/A*y6rgTbxv8nsAAAAAAAAAAABkARQnAQ" + } + ] +} diff --git a/examples/line/marker/demo/multi-markerpoint.js b/examples/line/marker/demo/multi-markerpoint.js new file mode 100644 index 0000000000..4bf9cac2f0 --- /dev/null +++ b/examples/line/marker/demo/multi-markerpoint.js @@ -0,0 +1,64 @@ +import { Line } from '@antv/g2plot'; + +const data = [ + { date: '2019-01-01', value: 3 }, + { date: '2019-02-01', value: 4 }, + { date: '2019-03-01', value: 3.5 }, + { date: '2019-04-01', value: 5 }, + { date: '2019-05-01', value: 4.9, festival: '劳动节' }, + { date: '2019-06-01', value: 6 }, + { date: '2019-07-01', value: 7 }, + { date: '2019-08-01', value: 9 }, + { date: '2019-09-01', value: -7, error: '异常' }, + { date: '2019-10-01', value: 13, festival: '国庆节' }, + { date: '2019-11-01', value: 13 }, + { date: '2019-12-01', value: 13 }, +]; + +const linePlot = new Line(document.getElementById('container'), { + title: { + visible: true, + text: '多种类型标注点', + }, + description: { + visible: true, + text: '在折线图上标注重点的数据,如节假日、异常点等', + }, + forceFit: true, + padding: 'auto', + data, + xField: 'date', + yField: 'value', + yAxis: { + nice: true, + }, + label: { + visible: false, + }, + markerPoints: [ + { + visible: true, + data: [{ date: '2019-05-01', value: 4.9 }, { date: '2019-10-01' }], + label: { + visible: true, + field: 'festival', + }, + }, + { + visible: true, + data: [{ date: '2019-09-01' }], + symbol: 'cross', + label: { + visible: true, + field: 'error', + position: 'bottom', + offsetY: 8, + }, + style: { + normal: { stroke: 'rgba(255, 0, 0, 0.65)', lineWidth: 2 }, + }, + }, + ], +}); + +linePlot.render(); diff --git a/examples/line/marker/design.en.md b/examples/line/marker/design.en.md new file mode 100644 index 0000000000..6d7c247589 --- /dev/null +++ b/examples/line/marker/design.en.md @@ -0,0 +1,5 @@ +--- +title: 设计规范 +--- + +设计规范 diff --git a/examples/line/marker/design.zh.md b/examples/line/marker/design.zh.md new file mode 100644 index 0000000000..02c31c8d1e --- /dev/null +++ b/examples/line/marker/design.zh.md @@ -0,0 +1,3 @@ +--- +title: 设计规范 +--- diff --git a/examples/line/marker/index.en.md b/examples/line/marker/index.en.md new file mode 100644 index 0000000000..7610a9f525 --- /dev/null +++ b/examples/line/marker/index.en.md @@ -0,0 +1,6 @@ +--- +title: Marker Point +order: 8 +--- + +Marker point in a chart, the items always are tied to points diff --git a/examples/line/marker/index.zh.md b/examples/line/marker/index.zh.md new file mode 100644 index 0000000000..d570d087e9 --- /dev/null +++ b/examples/line/marker/index.zh.md @@ -0,0 +1,6 @@ +--- +title: 标注点 +order: 8 +--- + +图表标注, 标注点与数据点绑定 diff --git a/src/components/marker-point.ts b/src/components/marker-point.ts new file mode 100644 index 0000000000..6b5e742efd --- /dev/null +++ b/src/components/marker-point.ts @@ -0,0 +1,332 @@ +import { View, IGroup, IShape, MarkerSymbols } from '../dependents'; +import { deepMix, isMatch, isString, isArray, each, find } from '@antv/util'; +import { MappingDatum, GAnimateCfg } from '@antv/g2/lib/interface'; +import { DEFAULT_ANIMATE_CFG } from '@antv/g2/lib/animate'; +import { Event } from '@antv/g2/lib/dependents'; + +interface PointStyle { + size?: number; + fill?: string; + stroke?: string; + lineWidth?: number; +} + +interface MarkerItem { + _origin: object; +} + +type AnimationOption = { + endState: PointStyle; + animateCfg: GAnimateCfg; +}; + +interface Cfg { + view: View; + data: any[]; + // 'image://http://xxx.xxx.xxx/a/b.png' + symbol?: string | ((x: number, y: number, r: number) => any[][]); + size?: number; + /** 标注点 point 坐标的 x 偏移 */ + offsetX?: number; + offsetY?: number; + label?: { + visible: boolean; + /** marker point 映射的字段 */ + field?: string; + /** _origin: 原始数据 */ + formatter?: (text: string, item: MarkerItem, index: number) => string; + position?: 'top' | 'bottom'; + offsetX?: number; + offsetY?: number; + style?: object; + }; + style?: { + normal?: PointStyle; + active?: PointStyle; + selected?: PointStyle; + }; + events?: { + mouseenter?: (e: Event) => void; + mouseleave?: (e: Event) => void; + click?: (e: Event) => void; + }; + animation?: boolean | AnimationOption; +} + +const DEFAULT_STYLE = { + stroke: 'transparent', + fill: '#FCC509', + lineWidth: 0, +}; + +const ACTIVE_STYLE = { + stroke: '#FFF', + fill: '#FCC509', + lineWidth: 1, +}; + +const SELECTED_STYLE = { + stroke: 'rgba(0,0,0,0.85)', + fill: '#FCC509', + lineWidth: 1, +}; + +interface StateCondition { + id: string; + data: {}; +} +type State = 'active' | 'inactive' | 'selected'; + +export { Cfg as MarkerPointCfg }; + +/** + * 标注点 绘制在最顶层 + */ +export default class MarkerPoint { + public view: View; + public container: IGroup; + public config: Cfg; + private points: IShape[] = []; + private labels: IShape[] = []; + private size: number; + private name = 'markerPoints'; + private selectedPoint: IShape; + + protected defaultCfg = { + offsetX: 0, + offsetY: 0, + style: { normal: DEFAULT_STYLE, selected: SELECTED_STYLE, active: ACTIVE_STYLE }, + events: { + mouseenter: () => {}, + mouseleave: () => {}, + click: () => {}, + }, + label: { + visible: false, + offsetY: -8, + position: 'top', + style: { + fill: 'rgba(0, 0, 0, 0.85)', + }, + }, + animation: false, + }; + + constructor(cfg: Cfg) { + this.view = cfg.view; + this.size = cfg.size || 6; + this.config = deepMix({}, this.defaultCfg, cfg); + this._init(); + } + + public render() { + const dataArray = this.getDataArray(); + this._renderPoints(dataArray); + this.view.canvas.draw(); + this._addInteraction(); + } + + public clear() { + if (this.container) { + this.container.clear(); + } + } + + public destroy() { + if (this.container) { + this.container.remove(); + } + this.points = []; + this.labels = []; + } + + protected getDataArray(): MappingDatum[][] { + const geometry = this.view.geometries[0]; + return geometry.dataArray; + } + + private _init() { + const layer = this.view.foregroundGroup; + this.container = layer.addGroup(); + this.render(); + this.view.on('beforerender', () => { + this.clear(); + }); + } + + private _renderPoints(dataArray: MappingDatum[][]) { + each(this.config.data, (dataItem, dataItemIdx) => { + const origin = find(dataArray[0], (d) => isMatch(d._origin, dataItem)); + if (origin) { + const pointAttrs = this.config.style.normal; + const group = this.container.addGroup({ name: this.name }); + let { x, y } = origin; + if (isArray(x)) { + x = x[0]; + } + if (isArray(y)) { + y = y[0]; + } + let symbol = this.config.symbol; + const { offsetX, offsetY } = this.config; + let point; + if (isString(symbol) && symbol.startsWith('image://')) { + const imageUrl = symbol.substr(8); + point = group.addShape('image', { + attrs: { + x: x - this.size / 2 + offsetX, + y: y - this.size / 2 + offsetY, + img: imageUrl, + width: this.size, + height: this.size, + }, + }); + } else { + symbol = isString(symbol) ? MarkerSymbols[symbol] : symbol; + point = group.addShape({ + type: 'marker', + name: 'marker-point', + id: `point-${dataItemIdx}`, + attrs: { + x: x + offsetX, + y: y + offsetY, + r: this.size / 2, + ...pointAttrs, + symbol, + }, + }); + } + this.points.push(point); + this._animatePoint(point); + this._renderLabel(group, origin, dataItemIdx); + group.set('data', dataItem); + group.set('origin', origin); + } + }); + } + + private _renderLabel(container: IGroup, origin: MappingDatum, index) { + const { label: labelCfg } = this.config; + if (labelCfg && labelCfg.visible) { + const { offsetX = 0, offsetY = 0, formatter, position, field } = labelCfg; + let text = origin._origin[field]; + if (formatter) { + text = formatter(text, { _origin: origin._origin }, index); + } + const x = isArray(origin.x) ? origin.x[0] : origin.x; + const y = isArray(origin.y) ? origin.y[0] : origin.y; + const label = container.addShape('text', { + name: 'marker-label', + id: `label-${index}`, + attrs: { + x: x + offsetX, + y: y + offsetY, + text: text || '', + ...labelCfg.style, + textAlign: 'center', + textBaseline: position === 'top' ? 'bottom' : 'top', + }, + }); + this.labels.push(label); + } + } + + private _addInteraction() { + const { events } = this.config; + each(events, (cb, eventName) => { + this.container.on(`${this.name}:${eventName}`, (e) => { + cb(e); + const target = e.target.get('parent'); + const pointShape = target.get('children')[0]; + if (pointShape) { + const data = pointShape.get('data'); + const id = pointShape.get('id'); + const condition = { id, data }; + if (eventName === 'click') { + if (this.selectedPoint && this.selectedPoint.get('id') === id) { + this.selectedPoint = null; + this.setState('inactive', condition); + } else { + this.selectedPoint = pointShape; + this.setState('selected', condition); + } + } else if (eventName === 'mouseenter') { + this.setState('active', condition); + } else if (eventName === 'mouseleave') { + this.setState('inactive', condition); + } + } + this.view.canvas.draw(); + }); + this.view.on('click', (e) => { + const target = e.target.get('parent'); + if (!target || (target.get('name') !== this.name && this.selectedPoint)) { + this.selectedPoint = null; + this.setState('inactive'); + } + }); + }); + } + + private setState(state: State, condition?: StateCondition) { + if (state === 'active') { + if (!this.selectedPoint || condition.id !== this.selectedPoint.get('id')) { + this._onActive(condition); + } + } else if (state === 'inactive') { + this.points.forEach((p) => this._onInactive(p)); + } else if (state === 'selected') { + this._onSelected(condition); + } + } + + private _onActive(condition?: StateCondition) { + const { active } = this.config.style; + each(this.points, (point) => { + if (point.get('id') === condition.id) { + each(active, (v, k) => { + point.attr(k, v); + }); + } else { + this._onInactive(point); + } + }); + } + + private _onInactive(point: IShape) { + const { normal } = this.config.style; + if (!this.selectedPoint || point.get('id') !== this.selectedPoint.get('id')) { + each(normal, (v, k) => { + point.attr(k, v); + }); + } + } + + private _onSelected(condition: StateCondition) { + const { selected } = this.config.style; + each(this.points, (point) => { + if (point.get('id') === condition.id) { + each(selected, (v, k) => { + point.attr(k, v); + }); + } else { + this._onInactive(point); + } + }); + } + + /** point animation, not for label */ + private _animatePoint(shape: IShape) { + const { animation, size } = this.config; + if (animation !== false) { + const { endState = {}, animateCfg = DEFAULT_ANIMATE_CFG.appear } = animation as AnimationOption; + shape.animate( + { + r: Number.isNaN(endState.size / 2) ? size / 2 : endState.size / 2, + ...endState, + }, + animateCfg + ); + } + } +} diff --git a/src/dependents.ts b/src/dependents.ts index 62a84c46ef..a190347402 100644 --- a/src/dependents.ts +++ b/src/dependents.ts @@ -17,6 +17,7 @@ export { getShapeFactory, } from '@antv/g2'; export { VIEW_LIFE_CIRCLE } from '@antv/g2/lib/constant'; +export { MarkerSymbols } from '@antv/g2/lib/util/marker'; export { Datum, Data, diff --git a/src/plots/line/layer.ts b/src/plots/line/layer.ts index 2627b40423..3bc39516d6 100644 --- a/src/plots/line/layer.ts +++ b/src/plots/line/layer.ts @@ -1,4 +1,4 @@ -import { deepMix, has, map, each } from '@antv/util'; +import { deepMix, has, map, each, get, some } from '@antv/util'; import { registerPlotType } from '../../base/global'; import { LayerConfig } from '../../base/layer'; import ViewLayer, { ViewConfig } from '../../base/view-layer'; @@ -10,6 +10,7 @@ import { getPlotOption } from './animation/clipIn-with-data'; import responsiveMethods from './apply-responsive'; import LineLabel from './component/label/line-label'; import * as EventParser from './event'; +import MarkerPoint, { MarkerPointCfg } from '../../components/marker-point'; import './theme'; import './apply-responsive/theme'; import { LooseMap } from '../../interface/types'; @@ -55,6 +56,9 @@ export interface LineViewConfig extends ViewConfig { color?: string; style?: PointStyle; }; + markerPoints?: (Omit & { + visible?: boolean; + })[]; xAxis?: IValueAxis | ICatAxis | ITimeAxis; yAxis?: IValueAxis; } @@ -88,16 +92,27 @@ export default class LineLayer exte position: 'top-left', wordSpacing: 4, }, + tooltip: { + crosshairs: { + line: { + style: { + stroke: 'rgba(0,0,0,0.45)', + }, + }, + }, + }, + markerPoints: [], }); } public line: any; // 保存line和point的配置项,用于后续的label、tooltip public point: any; public type: string = 'line'; + protected markerPoints: MarkerPoint[] = []; public afterRender() { - const props = this.options; - if (this.options.label && this.options.label.visible && this.options.label.type === 'line') { + const options = this.options; + if (options.label && options.label.visible && options.label.type === 'line') { const label = new LineLabel({ view: this.view, plot: this, @@ -105,8 +120,22 @@ export default class LineLayer exte }); label.render(); } + if (options.markerPoints) { + // 清空 + each(this.markerPoints, (markerPoint: MarkerPoint) => markerPoint.destroy()); + this.markerPoints = []; + options.markerPoints.forEach((markerPointOpt) => { + if (markerPointOpt.visible) { + const markerPoint = new MarkerPoint({ + ...markerPointOpt, + view: this.view, + }); + this.markerPoints.push(markerPoint); + } + }); + } // 响应式 - if (props.responsive && props.padding !== 'auto') { + if (options.responsive && options.padding !== 'auto') { this.applyResponsive('afterRender'); } super.afterRender(); @@ -139,6 +168,14 @@ export default class LineLayer exte protected coord() {} + protected tooltip() { + // 如果有标注点,则不展示markers + if (some(this.options.markerPoints, (markerPointOpt) => markerPointOpt.visible)) { + this.options.tooltip.showMarkers = false; + } + super.tooltip(); + } + protected addGeometry() { // 配置线 this.addLine(); diff --git a/src/plots/pie/component/label/base-label.ts b/src/plots/pie/component/label/base-label.ts index ccddaa73fe..0c428b3b64 100644 --- a/src/plots/pie/component/label/base-label.ts +++ b/src/plots/pie/component/label/base-label.ts @@ -1,7 +1,7 @@ import { IGroup, IShape, BBox } from '../../../../dependents'; import { transform } from '@antv/matrix-util'; import { deepMix, isString } from '@antv/util'; -import { getEndPoint, getLabelRotate, getAngleByPoint } from './utils'; +import { getEndPoint, getLabelRotate, getAngleByPoint, getOverlapArea, near } from './utils'; import { Label } from '../../../../interface/config'; import PieLayer from '../../layer'; import { getEllipsisText } from './utils/text'; @@ -14,6 +14,17 @@ export function percent2Number(value: string): number { return percentage / 100; } +/** + * 超出panel边界的标签默认隐藏 + */ +function checkInPanel(label: IShape, panel: BBox): void { + const box = label.getBBox(); + // 横向溢出 暂不隐藏 + if (!(panel.y <= box.y && panel.y + panel.height >= box.y + box.height)) { + label.get('parent').set('visible', false); + } +} + export interface LabelItem { x: number; y: number; @@ -28,6 +39,8 @@ export interface LabelItem { export interface PieLabelConfig extends Omit { visible: boolean; formatter?: (text: string, item: any, idx: number) => string; + /** whether */ + adjustPosition?: boolean; /** allow label overlap */ allowOverlap?: boolean; autoRotate?: boolean; @@ -65,6 +78,29 @@ export default abstract class PieBaseLabel { protected abstract getDefaultOptions(); protected abstract layout(labels: IShape[], shapeInfos: LabelItem[], panelBox: BBox); + /** 处理标签遮挡问题 */ + protected adjustOverlap(labels: IShape[], panel: BBox): void { + // clearOverlap; + for (let i = 1; i < labels.length; i++) { + const label = labels[i]; + let overlapArea = 0; + for (let j = i - 1; j >= 0; j--) { + const prev = labels[j]; + // fix: start draw point.x is error when textAlign is right + const prevBox = prev.getBBox(); + const currBox = label.getBBox(); + // if the previous one is invisible, skip + if (prev.get('parent').get('visible')) { + overlapArea = getOverlapArea(prevBox, currBox); + if (!near(overlapArea, 0)) { + label.get('parent').set('visible', false); + break; + } + } + } + } + labels.forEach((label) => checkInPanel(label, panel)); + } protected adjustItem(item: LabelItem): void {} protected init() { @@ -108,7 +144,7 @@ export default abstract class PieBaseLabel { /** 绘制文本 */ protected drawTexts() { - const { style, formatter, autoRotate, offsetX, offsetY } = this.options; + const { style, formatter, autoRotate, offsetX, offsetY, adjustPosition, allowOverlap } = this.options; const shapeInfos = this.getItems(); const shapes: IShape[] = []; shapeInfos.map((shapeInfo, idx) => { @@ -129,7 +165,12 @@ export default abstract class PieBaseLabel { const panelBox = this.coordinateBBox; this.adjustText(shape, panelBox); }); - this.layout(shapes, shapeInfos, this.coordinateBBox); + if (adjustPosition) { + this.layout(shapes, shapeInfos, this.coordinateBBox); + } + if (!allowOverlap) { + this.adjustOverlap(shapes, this.coordinateBBox); + } shapes.forEach((label, idx) => { if (autoRotate) { this.rotateLabel(label, getLabelRotate(shapeInfos[idx].angle)); diff --git a/src/plots/pie/component/label/outer-center-label.ts b/src/plots/pie/component/label/outer-center-label.ts index 4d17837e69..7195d96c7d 100644 --- a/src/plots/pie/component/label/outer-center-label.ts +++ b/src/plots/pie/component/label/outer-center-label.ts @@ -38,45 +38,5 @@ export default class PieOuterCenterLabel extends PieBaseLabel { } /** label 碰撞调整 */ - protected layout(labels: IShape[], items: LabelItem[], panel: BBox) { - this.adjustOverlap(labels, panel); - } - - /** 处理标签遮挡问题 */ - protected adjustOverlap(labels: IShape[], panel: BBox): void { - if (this.options.allowOverlap) { - return; - } - // clearOverlap; - for (let i = 1; i < labels.length; i++) { - const label = labels[i]; - let overlapArea = 0; - for (let j = i - 1; j >= 0; j--) { - const prev = labels[j]; - // fix: start draw point.x is error when textAlign is right - const prevBox = prev.getBBox(); - const currBox = label.getBBox(); - // if the previous one is invisible, skip - if (prev.get('parent').get('visible')) { - overlapArea = getOverlapArea(prevBox, currBox); - if (!near(overlapArea, 0)) { - label.get('parent').set('visible', false); - break; - } - } - } - } - labels.forEach((label) => this.checkInPanel(label, panel)); - } - - /** - * 超出panel边界的标签默认隐藏 - */ - protected checkInPanel(label: IShape, panel: BBox): void { - const box = label.getBBox(); - // 横向溢出 暂不隐藏 - if (!(panel.y <= box.y && panel.y + panel.height >= box.y + box.height)) { - label.get('parent').set('visible', false); - } - } + protected layout(labels: IShape[], items: LabelItem[], panel: BBox) {} } diff --git a/src/plots/pie/component/label/outer-label.ts b/src/plots/pie/component/label/outer-label.ts index 9c110fdfa2..799f956bde 100644 --- a/src/plots/pie/component/label/outer-label.ts +++ b/src/plots/pie/component/label/outer-label.ts @@ -37,45 +37,6 @@ export default class PieOuterLabel extends PieBaseLabel { [rightHalf, leftHalf].forEach((half, isLeft) => { this._antiCollision(half, !isLeft, panel); }); - this.adjustOverlap(labels, panel); - } - - /** 处理标签遮挡问题 */ - protected adjustOverlap(labels: IShape[], panel: BBox): void { - if (this.options.allowOverlap) { - return; - } - // clearOverlap; - for (let i = 1; i < labels.length; i++) { - const label = labels[i]; - let overlapArea = 0; - for (let j = i - 1; j >= 0; j--) { - const prev = labels[j]; - // fix: start draw point.x is error when textAlign is right - const prevBox = prev.getBBox(); - const currBox = label.getBBox(); - // if the previous one is invisible, skip - if (prev.get('parent').get('visible')) { - overlapArea = getOverlapArea(prevBox, currBox); - if (!near(overlapArea, 0)) { - label.get('parent').set('visible', false); - break; - } - } - } - } - labels.forEach((label) => this.checkInPanel(label, panel)); - } - - /** - * 超出panel边界的标签默认隐藏 - */ - protected checkInPanel(label: IShape, panel: BBox): void { - const box = label.getBBox(); - // 横向溢出 暂不隐藏 - if (!(panel.y <= box.y && panel.y + panel.height >= box.y + box.height)) { - label.get('parent').set('visible', false); - } } /** labels 碰撞处理(重点算法) */ diff --git a/src/plots/pie/layer.ts b/src/plots/pie/layer.ts index e61451b061..947d4013e7 100644 --- a/src/plots/pie/layer.ts +++ b/src/plots/pie/layer.ts @@ -53,6 +53,7 @@ export default class PieLayer extends visible: true, type: 'inner', autoRotate: false, + adjustPosition: true, allowOverlap: false, line: { visible: true,