diff --git a/__tests__/data/circle-packing.ts b/__tests__/data/circle-packing.ts new file mode 100644 index 0000000000..131d728c1f --- /dev/null +++ b/__tests__/data/circle-packing.ts @@ -0,0 +1,131 @@ +export const DATA = { + name: 'root', + children: [ + { + name: 'Drama', + value: 1046790, + }, + { + name: 'Comedy', + value: 1039358, + }, + { + name: 'Documentary', + value: 461880, + }, + { + name: 'News', + value: 308136, + }, + { + name: 'Talk-Show', + value: 270578, + }, + { + name: 'Action', + value: 226334, + }, + { + name: 'Animation', + value: 197342, + }, + { + name: 'Reality-TV', + value: 189739, + }, + { + name: 'Crime', + value: 175272, + }, + { + name: 'Family', + value: 150621, + }, + { + name: 'Short', + value: 138255, + }, + { + name: 'Adventure', + value: 121216, + }, + { + name: 'Game-Show', + value: 119912, + }, + { + name: 'Music', + value: 102488, + }, + { + name: 'Adult', + value: 90157, + }, + { + name: 'Biography', + value: 59307, + }, + { + name: 'Sport', + value: 58999, + }, + { + name: 'Romance', + value: 52776, + }, + { + name: 'Horror', + value: 50800, + }, + { + name: 'Fantasy', + value: 22614, + }, + { + name: 'Sci-Fi', + value: 22026, + }, + { + name: 'Thriller', + value: 19706, + }, + { + name: 'Mystery', + value: 18274, + }, + { + name: 'History', + value: 16108, + }, + { + name: 'Western', + value: 12535, + }, + { + name: 'Musical', + value: 12240, + }, + { + name: 'War', + value: 1992, + }, + { + name: 'Film-Noir', + value: 12240 + 1992 + 1992, + children: [ + { + name: 'Musical', + value: 12240, + }, + { + name: 'War', + value: 1992, + }, + { + name: 'Film', + value: 1992, + }, + ], + }, + ], +}; diff --git a/__tests__/unit/plots/circle-packing/animation-spec.ts b/__tests__/unit/plots/circle-packing/animation-spec.ts new file mode 100644 index 0000000000..a168039767 --- /dev/null +++ b/__tests__/unit/plots/circle-packing/animation-spec.ts @@ -0,0 +1,89 @@ +import { CirclePacking } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; +import { DATA } from '../../../data/circle-packing'; + +describe('Circle-Packing', () => { + const div = createDiv(); + const plot = new CirclePacking(div, { + autoFit: true, + padding: 0, + data: DATA, + animation: { + appear: { + animation: 'zoom-in', + duration: 500, + }, + leave: { + animation: 'zoom-out', + duration: 500, + }, + }, + }); + plot.render(); + + it('default', () => { + // + expect(plot.chart.geometries[0].animateOption).toEqual({ + appear: { + animation: 'zoom-in', + duration: 500, + easing: 'easeQuadOut', + }, + update: { + duration: 400, + easing: 'easeQuadInOut', + }, + enter: { + duration: 400, + easing: 'easeQuadInOut', + animation: 'zoom-in', + }, + leave: { + duration: 500, + easing: 'easeQuadIn', + animation: 'zoom-out', + }, + }); + }); + + it('update', () => { + plot.update({ + animation: { + appear: { + animation: 'fade-in', + }, + enter: { + animation: 'fade-in', + }, + leave: { + animation: 'wave-out', + }, + }, + }); + expect(plot.chart.geometries[0].animateOption).toEqual({ + appear: { + animation: 'fade-in', + duration: 500, + easing: 'easeQuadOut', + }, + update: { + duration: 400, + easing: 'easeQuadInOut', + }, + enter: { + duration: 400, + easing: 'easeQuadInOut', + animation: 'fade-in', + }, + leave: { + duration: 500, + easing: 'easeQuadIn', + animation: 'wave-out', + }, + }); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/circle-packing/index-spec.ts b/__tests__/unit/plots/circle-packing/index-spec.ts new file mode 100644 index 0000000000..6024b0211e --- /dev/null +++ b/__tests__/unit/plots/circle-packing/index-spec.ts @@ -0,0 +1,134 @@ +import { CirclePacking } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; +import { DATA } from '../../../data/circle-packing'; +import { DEFAULT_OPTIONS } from '../../../../src/plots/circle-packing/constant'; +import { getContainerSize } from '../../../../src/utils'; + +describe('Circle-Packing', () => { + const div = createDiv(); + const plot = new CirclePacking(div, { + padding: 0, + data: DATA, + legend: false, + hierarchyConfig: { + sort: (a, b) => b.depth - a.depth, + }, + }); + plot.render(); + + it('default', () => { + expect(plot.type).toBe('circle-packing'); + // @ts-ignore + expect(plot.getDefaultOptions()).toBe(CirclePacking.getDefaultOptions()); + + const geometry = plot.chart.geometries[0]; + expect(geometry.type).toBe('point'); + + const positionFields = geometry.getAttribute('position').getFields(); + expect(geometry.elements.length).toBe(geometry.data.length); + expect(positionFields).toHaveLength(2); + expect(positionFields).toEqual(['x', 'y']); + + // 圆形布局 宽高一致,即正常 + const coordinateBox = plot.chart.coordinateBBox; + const { width, height } = plot.chart.viewBBox; + const minSize = Math.min(width, height); + expect(coordinateBox.width).toBe(coordinateBox.height); + expect(minSize).toBe(coordinateBox.width); + expect(minSize).toBe(coordinateBox.height); + }); + + it('color', () => { + plot.update({ color: ['red', 'green', 'blue'] }); + + const geometry = plot.chart.geometries[0]; + const elements = geometry.elements; + expect(elements.length).toBe(plot.chart.getData().length); + + // 绘图数据 + expect(elements[0].getModel().color).toBe('red'); + expect(elements[1].getModel().color).toBe('green'); + expect(elements[7].getModel().color).toBe('green'); + expect(elements[14].getModel().color).toBe('blue' /** 15 % 3 === 0 */); + }); + + it('style', () => { + plot.update({ pointStyle: { fill: 'red', fillOpacity: 1 } }); + + const geometry = plot.chart.geometries[0]; + let elements = geometry.elements; + expect(elements.length).toBe(plot.chart.getData().length); + + // 绘图数据 + expect(elements[0].shape.attr('fillOpacity')).toBe(1); + expect(elements[1].shape.attr('fillOpacity')).toBe(1); + expect(elements[1].shape.attr('fill')).toBe('red'); + expect(elements[7].shape.attr('fillOpacity')).toBe(1); + expect(elements[13].shape.attr('fillOpacity')).toBe(1); + expect(elements[14].shape.attr('fillOpacity')).toBe(1); + + // callback + plot.update({ + rawFields: ['depth'], + pointStyle: ({ depth }) => ({ + fill: 'red', + fillOpacity: depth > 1 ? 1 : 0.5, + stroke: 'green', + lineWidth: depth, + }), + }); + elements = plot.chart.geometries[0].elements; + // 绘图数据 + expect(elements[0].shape.attr('fillOpacity')).toBe(0.5); + expect(elements[0].shape.attr('stroke')).toBe('green'); + + expect(elements[0].shape.attr('fillOpacity')).toBe(0.5); + expect(elements[elements.length - 1].shape.attr('fillOpacity')).toBe(1); + + expect(elements[0].shape.attr('lineWidth')).toBe(0); + expect(elements[1].shape.attr('lineWidth')).toBe(1); + expect(elements[elements.length - 1].shape.attr('lineWidth')).toBe(2); + }); + + it('label', () => { + let geometry = plot.chart.geometries[0]; + let labelGroup = plot.chart.geometries[0].labelsContainer.getChildren()[0]; + expect(typeof plot.chart.geometries[0].labelOption).toBe('object'); + // @ts-ignore + expect(labelGroup.getChildByIndex(0).attr('text')).toBe(DATA.name); + // @ts-ignore + expect(plot.chart.geometries[0].labelsContainer.getChildren()[1].getChildByIndex(0).attr('text')).toBe( + DATA.children[0].name + ); + + plot.update({ label: { fields: ['value'] } }); + labelGroup = plot.chart.geometries[0].labelsContainer.getChildren()[0]; + const filterData = plot.chart.getData(); + // @ts-ignore + expect(labelGroup.getChildByIndex(0).attr('text')).toBe(`${filterData[0].value}`); + + // meta + plot.update({ meta: { value: { formatter: (v) => v + '%' } } }); + labelGroup = plot.chart.geometries[0].labelsContainer.getChildren()[0]; + // @ts-ignore + expect(labelGroup.getChildByIndex(0).attr('text')).toBe(`${filterData[0].value}%`); + + // formatter + plot.update({ label: { formatter: () => 'xxx' } }); + geometry = plot.chart.geometries[0]; + // @ts-ignore + expect(geometry.labelsContainer.getChildren()[0].getChildByIndex(0).attr('text')).toBe('xxx'); + + // 关闭 + plot.update({ label: false }); + expect(plot.chart.geometries[0].labelOption).toBe(false); + }); + + it('defaultOptions 保持从 constants 中获取', () => { + expect(CirclePacking.getDefaultOptions()).toEqual(DEFAULT_OPTIONS); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/circle-packing/tooltip-spec.ts b/__tests__/unit/plots/circle-packing/tooltip-spec.ts new file mode 100644 index 0000000000..3fe3910563 --- /dev/null +++ b/__tests__/unit/plots/circle-packing/tooltip-spec.ts @@ -0,0 +1,149 @@ +import { TooltipCfg } from '@antv/g2/lib/interface'; +import { CirclePacking } from '../../../../src'; +import { createDiv, removeDom } from '../../../utils/dom'; +import { DATA } from '../../../data/circle-packing'; +import { DEFAULT_OPTIONS } from '../../../../src/plots/circle-packing/constant'; + +describe('Circle-Packing', () => { + const div = createDiv(); + const plot = new CirclePacking(div, { + autoFit: true, + padding: 0, + data: DATA, + label: false, + }); + plot.render(); + + it('default', () => { + // 默认有tooltip + const tooltipOptions = plot.chart.getOptions().tooltip as TooltipCfg; + expect(tooltipOptions).not.toBe(false); + expect(tooltipOptions).toMatchObject(DEFAULT_OPTIONS.tooltip); + // @ts-ignore 默认不展示 markers 和 title + expect(tooltipOptions.showMarkers).toBe(false); + expect(tooltipOptions.showTitle).toBe(false); + // @ts-ignore isVisible + expect(plot.chart.getController('tooltip').isVisible()).toBe(true); + }); + + it('meta', () => { + plot.update({ + meta: { + name: { + formatter: (v) => `名称:${v}`, + }, + value: { + formatter: (v) => `值:${v} `, + }, + }, + }); + const tooltipController = plot.chart.getController('tooltip'); + const box = plot.chart.geometries[0].elements[2].shape.getBBox(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + + const items = tooltipController.getTooltipItems(point); + expect(items.length).toBe(1); + expect(items[0].name).toBe('名称:Comedy'); + + plot.chart.showTooltip(point); + expect(div.querySelectorAll('.g2-tooltip-list-item').length).toBe(1); + expect((div.querySelector('.g2-tooltip-name') as HTMLElement).innerText).toBe(`名称:Comedy`); + expect((div.querySelector('.g2-tooltip-value') as HTMLElement).innerText).toBe(`值:1039358`); + plot.chart.hideTooltip(); + }); + + it('tooltip: fields', () => { + plot.update({ meta: undefined }); + const tooltipController = plot.chart.getController('tooltip'); + const box = plot.chart.geometries[0].elements[2].shape.getBBox(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + + plot.chart.showTooltip(point); + expect(div.querySelectorAll('.g2-tooltip-list-item').length).toBe(1); + plot.chart.hideTooltip(); + + plot.update({ + meta: { + name: { alias: '名称', formatter: (v) => `🌞 ${v}` }, + value: { alias: '数值' }, + depth: { alias: '深度' }, + }, + tooltip: { + fields: ['name', 'value', 'depth'], + }, + }); + const items = tooltipController.getTooltipItems(point); + expect(items.length).toBe(3); + expect(items[0].name).toBe('名称'); + expect(items[1].name).toBe('数值'); + expect(items[2].name).toBe('深度'); + + plot.chart.showTooltip(point); + expect(div.querySelectorAll('.g2-tooltip-list-item').length).toBe(3); + expect((div.querySelector('.g2-tooltip-name') as HTMLElement).innerText).toBe('名称'); + expect((div.querySelectorAll('.g2-tooltip-value')[1] as HTMLElement).innerText).toBe( + `${plot.chart.getData()[2].value}` + ); + expect((div.querySelectorAll('.g2-tooltip-value')[2] as HTMLElement).innerText).toBe( + `${plot.chart.getData()[2].depth}` + ); + plot.chart.hideTooltip(); + }); + + it('tooltip: formatter', () => { + plot.update({ + tooltip: { + fields: ['name', 'value', 'path'], + formatter: () => ({ name: 'name', value: 'value' }), + }, + }); + const tooltipController = plot.chart.getController('tooltip'); + const box = plot.chart.geometries[0].elements[0].shape.getBBox(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + + const items = tooltipController.getTooltipItems(point); + // fixme G2 现在的 bug,只能展示一条 + expect(items.length).toBe(1); + expect(items[0].name).toBe('name'); + expect(items[0].value).toBe('value'); + + plot.chart.showTooltip(point); + expect(div.querySelectorAll('.g2-tooltip-list-item').length).toBe(1); + expect((div.querySelector('.g2-tooltip-name') as HTMLElement).innerText).toBe('name'); + expect((div.querySelector('.g2-tooltip-value') as HTMLElement).innerText).toBe('value'); + plot.chart.hideTooltip(); + }); + + it('tooltip: customContent', () => { + plot.update({ + meta: undefined, + tooltip: { + fields: ['name', 'value'], + formatter: undefined, + customContent: (title, items) => + `
${items.map((item) => `${item.value}`)}
`, + }, + }); + const box = plot.chart.geometries[0].elements[1].shape.getBBox(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + + plot.chart.showTooltip(point); + const chartData = plot.chart.getData(); + expect((div.querySelectorAll('.custom-tooltip-value')[0] as HTMLElement).innerText).toBe(`${chartData[1].name}`); + expect((div.querySelectorAll('.custom-tooltip-value')[1] as HTMLElement).innerText).toBe(`${chartData[1].value}`); + plot.chart.hideTooltip(); + }); + + it('tooltip: hide', () => { + plot.update({ tooltip: false }); + // @ts-ignore + expect(plot.chart.options.tooltip).toBe(false); + // @ts-ignore + expect(plot.chart.getController('tooltip').isVisible()).toBe(false); + }); + + afterAll(() => { + plot.destroy(); + removeDom(div); + }); +}); diff --git a/docs/api/plots/circle-packing.en.md b/docs/api/plots/circle-packing.en.md new file mode 100644 index 0000000000..69e0045ba6 --- /dev/null +++ b/docs/api/plots/circle-packing.en.md @@ -0,0 +1,187 @@ +--- +title: Circle packing +order: 40 +--- + +### Plot Container + +`markdown:docs/common/chart-options.en.md` + +### Data Mapping + +#### data + +**required** _object_ + +Configure the chart data source. For Circle packing: + +```sign +type Node = { name: string; value?: number; children: Node[]; } +``` + +示例: + +```ts +{ + name: 'root', + children: [ + { name: 'type1', value: 1 }, + { name: 'type2', value: 3, children: [{ name: 'type2-1', value: 2 }] } + ] +} +``` + +#### meta + +`markdown:docs/common/meta.en.md` + + +Circle packing contains data fields such as 'x', 'y', 'r', 'name', 'value', 'path', and 'depth', which can be retrieved from the metadata (used in tooltip and style callbacks). + +You can set the meta information of a field as follows: + +```ts +meta: { + name: { + formatter: (v) => `名称:${v}`, + }, + value: { + alias: '值', + }, + depth: { + alias: '深度', + } +}, +``` + +#### colorField + +**optional** _string_ + +Color mapping field. The default is: 'name', and the colors are sorted by name field. + +#### sizeField + +**optional** _string_ + +The name of the data field corresponding to the point size map. + +#### rawFields + +**optional** _string[]_ + +Extra original fields. Once configured, you can retrieve additional raw data in the datum parameter of callback functions such as Tooltip. + +### Geometry Style + +#### hierarchyConfig ✨ + +**optional** _object_ + +Hierarchy configuration, such as' size ', 'padding', etc., refer to [D3-Hierarchy](https://github.com/d3/d3-hierarchy#pack) for detailed configuration. + +Supports configuration properties: + +| Properties | Type | Description | | +| ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| field | _string_ | The data node weight mapping field, default is: 'value'. When your node data format is not: '{name: 'xx', value: 'xx'} ', you can use this field to specify. See the example chart for details | +| padding | _number\|number[]_ | default: `0`。reference:[d3-hierarchy#pack_padding](https://github.com/d3/d3-hierarchy#pack_padding) | +| size | _number[]_ | default: `[1, 1]`。reference:[d3-hierarchy#pack_size](https://github.com/d3/d3-hierarchy#pack_size) | +| sort | _Function_ | Data node sorting method, default: descending order.reference: [d3-hierarchy#node_sort](https://github.com/d3/d3-hierarchy#node_sort) | + + + + +`markdown:docs/common/color.en.md` + +#### pointStyle + +**optional** _object_ + +Set the point style. The `fill` in pointStyle overrides the configuration of `color`. PointStyle can be specified either directly or via a callback to specify individual styles based on the data. + +Default configuration: + +| Properties | Type | Description | +| ------------- | ------ | --------------------- | +| fill | string | Fill color | +| stroke | string | Stroke color | +| lineWidth | number | Line width | +| lineDash | number | The dotted lines show | +| opacity | number | Transparency | +| fillOpacity | number | Fill transparency | +| strokeOpacity | number | Stroke transparency | + +```ts +// Specified directly +{ + pointStyle: { + fill: 'red', + stroke: 'yellow', + opacity: 0.8 + }, +} +// Function +{ + pointStyle: ({ value }) => { + if (value > 50000) { + return { + fill: 'green', + stroke: 'yellow', + opacity: 0.8, + } + } + // TODO + return { + fill: 'red', + stroke: 'yellow', + opacity: 0.8, + } + } +} +``` + +#### reflect + +**optional** _x | y_ + +You can use `reflect: 'x'` to carry out an X-axis reverse and `reflect: 'y'` to carry out a Y-axis reverse. + +### Plot Components + +`markdown:docs/common/component-polygon.en.md` + +### Plot Interactions + + + + +`markdown:docs/common/interactions.en.md` + +### Plot Event + +`markdown:docs/common/events.en.md` + +### Plot Method + +`markdown:docs/common/chart-methods.en.md` + +### Plot Theme + +`markdown:docs/common/theme.en.md` diff --git a/docs/api/plots/circle-packing.zh.md b/docs/api/plots/circle-packing.zh.md new file mode 100644 index 0000000000..188b6cd95a --- /dev/null +++ b/docs/api/plots/circle-packing.zh.md @@ -0,0 +1,188 @@ +--- +title: Circle packing +order: 40 +--- + +### 图表容器 + +`markdown:docs/common/chart-options.zh.md` + +### 数据映射 + +#### data + +**required** _object_ + +设置图表数据源。 Circle Packing 的数据格式要求为: + +```sign +type Node = { name: string; value?: number; children: Node[]; } +``` + +示例: + +```ts +{ + name: 'root', + children: [ + { name: 'type1', value: 1 }, + { name: 'type2', value: 3, children: [{ name: 'type2-1', value: 2 }] } + ] +} +``` + +#### meta + +`markdown:docs/common/meta.zh.md` + +Circle packing 内含的数据字段有:'x', 'y', 'r', 'name', 'value', 'path', 'depth', 这些字段可以在元数据中获取(tooltip、style 回调中使用). + +可以通过下面的方式来设置字段的元信息: + +```ts +meta: { + name: { + formatter: (v) => `名称:${v}`, + }, + value: { + alias: '值', + }, + depth: { + alias: '深度', + } +}, +``` + +#### colorField + +**optional** _string_ + +颜色映射字段。默认为:`name`,按照 name 字段对颜色进行分类。 + +#### sizeField + +**optional** _string_ + +点大小映射对应的数据字段名。 + +#### rawFields + +**optional** _string[]_ + +额外的原始字段。配置之后,可以在 tooltip 等回调函数的 datum 参数中,获取到更多额外的原始数据。 + + + + +### 图形样式 + +#### hierarchyConfig ✨ + +**optional** _object_ + +层级布局配置,参考[d3-hierarchy](https://github.com/d3/d3-hierarchy#pack)。 + +支持配置属性: + +| 属性 | 类型 | 描述 | | +| ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| field | _string_ | 数据节点权重映射字段,默认为:`value`. 当你的节点数据格式不是:`{ name: 'xx', value: 'xx' }`, 可以通过该字段来指定,详细见:图表示例 | +| padding | _number\|number[]_ | 默认:`0`。参考:[d3-hierarchy#pack_padding](https://github.com/d3/d3-hierarchy#pack_padding) | +| size | _number[]_ | 默认:`[1, 1]`。参考:[d3-hierarchy#pack_size](https://github.com/d3/d3-hierarchy#pack_size) | +| sort | _Function_ | 数据节点排序方式,默认:降序。参考: [d3-hierarchy#node_sort](https://github.com/d3/d3-hierarchy#node_sort) | + + + + + + + + +`markdown:docs/common/color.zh.md` + +#### pointStyle + +**optional** _object_ + +设置点样式。pointStyle 中的`fill`会覆盖 `color` 的配置。pointStyle 可以直接指定,也可以通过 callback 的方式,根据数据指定单独的样式。 + +默认配置: + +| 细分配置 | 类型 | 功能描述 | +| ------------- | ------ | ---------- | +| fill | string | 填充颜色 | +| stroke | string | 描边颜色 | +| lineWidth | number | 线宽 | +| lineDash | number | 虚线显示 | +| opacity | number | 透明度 | +| fillOpacity | number | 填充透明度 | +| strokeOpacity | number | 描边透明度 | + +```ts +// 直接指定 +{ + pointStyle: { + fill: 'red', + stroke: 'yellow', + opacity: 0.8 + }, +} +// Function +{ + pointStyle: ({ value }) => { + if (value > 50000) { + return { + fill: 'green', + stroke: 'yellow', + opacity: 0.8, + } + } + // TODO + return { + fill: 'red', + stroke: 'yellow', + opacity: 0.8, + } + } +} +``` + + +#### reflect + +**optional** _x | y_ + +可使用 `reflect: 'x'` 进行 x 轴反转,使用 `reflect: 'y'` 进行 y 轴反转。 + +### 图表组件 + +`markdown:docs/common/component-polygon.zh.md` + +### 图表交互 + + + + + +`markdown:docs/common/interactions.zh.md` + +### 图表事件 + +`markdown:docs/common/events.zh.md` + +### 图表方法 + +`markdown:docs/common/chart-methods.zh.md` + +### 图表主题 + +`markdown:docs/common/theme.zh.md` diff --git a/examples/more-plots/circle-packing/API.en.md b/examples/more-plots/circle-packing/API.en.md new file mode 100644 index 0000000000..460510625f --- /dev/null +++ b/examples/more-plots/circle-packing/API.en.md @@ -0,0 +1 @@ +`markdown:docs/api/plots/circle-packing.en.md` \ No newline at end of file diff --git a/examples/more-plots/circle-packing/API.zh.md b/examples/more-plots/circle-packing/API.zh.md new file mode 100644 index 0000000000..92149474ce --- /dev/null +++ b/examples/more-plots/circle-packing/API.zh.md @@ -0,0 +1 @@ +`markdown:docs/api/plots/circle-packing.zh.md` \ No newline at end of file diff --git a/examples/more-plots/circle-packing/demo/basic.ts b/examples/more-plots/circle-packing/demo/basic.ts new file mode 100644 index 0000000000..4185118390 --- /dev/null +++ b/examples/more-plots/circle-packing/demo/basic.ts @@ -0,0 +1,17 @@ +import { CirclePacking } from '@antv/g2plot'; + +fetch('https://gw.alipayobjects.com/os/antfincdn/%24m0nDoQYqH/basic-packing.json') + .then((data) => data.json()) + .then((data) => { + const plot = new CirclePacking('container', { + autoFit: true, + data, + label: false, + legend: false, + hierarchyConfig: { + sort: (a, b) => b.depth - a.depth, + }, + }); + + plot.render(); + }); diff --git a/examples/more-plots/circle-packing/demo/custom-padding.ts b/examples/more-plots/circle-packing/demo/custom-padding.ts new file mode 100644 index 0000000000..2f44038a77 --- /dev/null +++ b/examples/more-plots/circle-packing/demo/custom-padding.ts @@ -0,0 +1,19 @@ +import { CirclePacking } from '@antv/g2plot'; +fetch('https://gw.alipayobjects.com/os/antfincdn/%24m0nDoQYqH/basic-packing.json') + .then((data) => data.json()) + .then((data) => { + const plot = new CirclePacking('container', { + autoFit: true, + padding: 0, + data, + hierarchyConfig: { + padding: 0.01, + }, + color: 'rgb(252, 253, 191)-rgb(231, 82, 99)-rgb(183, 55, 121)', + // 自定义 label 样式 + label: false, + legend: false, + }); + + plot.render(); + }); diff --git a/examples/more-plots/circle-packing/demo/label.ts b/examples/more-plots/circle-packing/demo/label.ts new file mode 100644 index 0000000000..edddf843a3 --- /dev/null +++ b/examples/more-plots/circle-packing/demo/label.ts @@ -0,0 +1,28 @@ +import { CirclePacking } from '@antv/g2plot'; +fetch('https://gw.alipayobjects.com/os/antfincdn/%24m0nDoQYqH/basic-packing.json') + .then((data) => data.json()) + .then((data) => { + const plot = new CirclePacking('container', { + autoFit: true, + padding: 0, + data, + sizeField: 'r', + color: 'rgb(252, 253, 191)-rgb(231, 82, 99)-rgb(183, 55, 121)', + // 自定义 label 样式 + label: { + formatter: ({ name }) => { + return name !== 'root' ? name : ''; + }, + // 偏移 + offsetY: 8, + style: { + fontSize: 12, + textAlign: 'center', + fill: 'rgba(0,0,0,0.65)', + }, + }, + legend: false, + }); + + plot.render(); + }); diff --git a/examples/more-plots/circle-packing/demo/meta.json b/examples/more-plots/circle-packing/demo/meta.json new file mode 100644 index 0000000000..4436fe01c2 --- /dev/null +++ b/examples/more-plots/circle-packing/demo/meta.json @@ -0,0 +1,40 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "basic.ts", + "title": { + "zh": "Circle packing", + "en": "Circle packing" + }, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/AF1%24co0stf/7db34ba1-5adc-4fca-ab40-fdeb45195ffb.png" + }, + { + "filename": "nest.ts", + "title": { + "zh": "多层 circle packing", + "en": "Nest circle packing" + }, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/0OctH62S1l/fb31a01b-f609-4239-a5c3-358da994432e.png" + }, + { + "filename": "label.ts", + "title": { + "zh": "展示数据标签", + "en": "Display label" + }, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/0OctH62S1l/fb31a01b-f609-4239-a5c3-358da994432e.png" + }, + { + "filename": "custom-padding.ts", + "title": { + "zh": "自定义圆圈之间的 padding 距离", + "en": "Custom padding between circles" + }, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/dCP6LawPW0/7ffa2fef-af09-4df4-8db3-b8f1ddbc63a5.png" + } + ] +} diff --git a/examples/more-plots/circle-packing/demo/nest.ts b/examples/more-plots/circle-packing/demo/nest.ts new file mode 100644 index 0000000000..bb26b9bf5f --- /dev/null +++ b/examples/more-plots/circle-packing/demo/nest.ts @@ -0,0 +1,30 @@ +import { CirclePacking } from '@antv/g2plot'; + +fetch('https://gw.alipayobjects.com/os/antvdemo/assets/data/flare.json') + .then((data) => data.json()) + .then((data) => { + const plot = new CirclePacking('container', { + autoFit: true, + padding: 0, + data, + sizeField: 'r', + // 自定义颜色 + colorField: 'r', + color: 'rgb(252, 253, 191)-rgb(231, 82, 99)-rgb(183, 55, 121)', + // 自定义样式 + pointStyle: { + stroke: 'rgb(183, 55, 121)', + lineWidth: 0.5, + }, + label: false, + legend: false, + drilldown: { + enabled: true, + breadCrumb: { + position: 'top-left', + }, + }, + }); + + plot.render(); + }); diff --git a/examples/more-plots/circle-packing/index.en.md b/examples/more-plots/circle-packing/index.en.md new file mode 100644 index 0000000000..16e4d81039 --- /dev/null +++ b/examples/more-plots/circle-packing/index.en.md @@ -0,0 +1,4 @@ +--- +title: Circle Packing +order: 40 +--- diff --git a/examples/more-plots/circle-packing/index.zh.md b/examples/more-plots/circle-packing/index.zh.md new file mode 100644 index 0000000000..16e4d81039 --- /dev/null +++ b/examples/more-plots/circle-packing/index.zh.md @@ -0,0 +1,4 @@ +--- +title: Circle Packing +order: 40 +--- diff --git a/package.json b/package.json index da0f14fd0c..d0012570bc 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "@antv/event-emitter": "^0.1.2", - "@antv/g2": "^4.1.0", + "@antv/g2": "^4.1.19", "d3-hierarchy": "^2.0.0", "d3-regression": "^1.3.5", "pdfast": "^0.2.0", diff --git a/src/index.ts b/src/index.ts index e3c6d6284d..d7ab9baeea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,6 +165,10 @@ export type { MixOptions } from './plots/mix'; export { Facet } from './plots/facet'; export type { FacetOptions } from './plots/facet'; +// 分面图及类型定义 | author by [visiky](https://github.com/visiky), [Angeli](https://github.com/Angelii) +export { CirclePacking } from './plots/circle-packing'; +export type { CirclePackingOptions } from './plots/circle-packing'; + /** 开发 adaptor 可能会用到的方法或一些工具方法,不强制使用 */ export { flow, measureTextWidth } from './utils'; diff --git a/src/interactions/actions/drill-down.ts b/src/interactions/actions/drill-down.ts index cca6057eae..d86d0339ab 100644 --- a/src/interactions/actions/drill-down.ts +++ b/src/interactions/actions/drill-down.ts @@ -95,9 +95,10 @@ export class DrillDownAction extends Action { const { position } = this.getButtonCfg(); // 默认,左上角直接出发 - let point = { x: 0, y: 0 }; + let point = coordinate.convert({ x: 0, y: 1 }); if (position === 'bottom-left') { - point = coordinate.isPolar ? { x: 0, y: coordinate.getHeight() } : coordinate.convert({ x: 0, y: 1 }); + // 涉及到坐标反转的问题 + point = coordinate.convert({ x: 0, y: 0 }); } /** PADDING_LEFT, PADDING_TOP 与画布边缘的距离 */ const matrix = Util.transform(null, [['t', point.x + PADDING_LEFT, point.y + bbox.height + PADDING_TOP]]); @@ -156,7 +157,7 @@ export class DrillDownAction extends Action { } const { view } = this.context; - const data = last(historyCache).children; + const data = last(historyCache).children; // 处理后的数组 view.changeData(data); if (historyCache.length > 1) { diff --git a/src/plots/_template/adaptor.ts b/src/plots/_template/adaptor.ts index 2acb6a9344..80bf6e5f6c 100644 --- a/src/plots/_template/adaptor.ts +++ b/src/plots/_template/adaptor.ts @@ -41,11 +41,11 @@ export function meta(params: Params): Params { export function adaptor(params: Params) { // flow 的方式处理所有的配置到 G2 API return flow( + theme, geometry, meta, interaction, - animation, - theme + animation // ... 其他的 adaptor flow )(params); } diff --git a/src/plots/circle-packing/adaptor.ts b/src/plots/circle-packing/adaptor.ts new file mode 100644 index 0000000000..baeeac7b7e --- /dev/null +++ b/src/plots/circle-packing/adaptor.ts @@ -0,0 +1,221 @@ +import { get } from '@antv/util'; +import { Types } from '@antv/g2'; +import { point } from '../../adaptor/geometries/point'; +import { Params } from '../../core/adaptor'; +import { interaction as baseInteraction, animation, theme, legend, annotation, scale } from '../../adaptor/common'; +import { flow, deepAssign } from '../../utils'; +import { getAdjustAppendPadding } from '../../utils/padding'; +import { transformData, resolvePaddingForCircle, resolveAllPadding } from './utils'; +import { CirclePackingOptions } from './types'; +import { RAW_FIELDS } from './constant'; + +/** + * 获取默认 option + * @param params + */ +function defaultOptions(params: Params): Params { + const { chart } = params; + const diameter = Math.min(chart.viewBBox.width, chart.viewBBox.height); + + return deepAssign( + { + options: { + size: ({ r }) => r * diameter, // 当autofit:false时,默认给固定半径 + }, + }, + params + ); +} + +/** + * padding 配置 + * @param params + */ +function padding(params: Params): Params { + const { options, chart } = params; + // 通过改变 padding,修改 coordinate 的绘制区域 + const containerSize = chart.viewBBox; + const { padding, appendPadding, drilldown } = options; + + let tempAppendPadding = appendPadding; + if (drilldown?.enabled) { + const appendPaddingByDrilldown = getAdjustAppendPadding( + chart.appendPadding, + get(drilldown, ['breadCrumb', 'position']) + ); + tempAppendPadding = resolveAllPadding([appendPaddingByDrilldown, appendPadding]); + } + + const { finalPadding } = resolvePaddingForCircle(padding, tempAppendPadding, containerSize); + chart.padding = finalPadding; + chart.appendPadding = 0; + + return params; +} + +/** + * 字段 + * @param params + */ +function geometry(params: Params): Params { + const { chart, options } = params; + const { padding, appendPadding } = chart; + const { color, colorField, pointStyle, hierarchyConfig, sizeField, rawFields = [], drilldown } = options; + + const data = transformData({ + data: options.data, + hierarchyConfig, + enableDrillDown: drilldown?.enabled, + rawFields, + }); + chart.data(data); + + const containerSize = chart.viewBBox; + const { finalSize } = resolvePaddingForCircle(padding, appendPadding, containerSize); + // 有sizeField的时候,例如 value ,可以选择映射 size 函数,自己计算出映射的半径 + let circleSize = ({ r }) => r * finalSize; // 默认配置 + + if (sizeField) { + circleSize = (d) => d[sizeField] * finalSize; // 目前只有 r 通道映射效果会正常 + } + + // geometry + point( + deepAssign({}, params, { + options: { + xField: 'x', + yField: 'y', + seriesField: colorField, + sizeField, + rawFields: [...RAW_FIELDS, ...rawFields], + point: { + color, + style: pointStyle, + shape: 'circle', + size: circleSize, + }, + }, + }) + ); + + return params; +} + +/** + * meta 配置 + * @param params + */ +export function meta(params: Params): Params { + return flow( + scale( + {}, + { + // 必须强制为 nice + x: { min: 0, max: 1, minLimit: 0, maxLimit: 1, nice: true }, + y: { min: 0, max: 1, minLimit: 0, maxLimit: 1, nice: true }, + } + ) + )(params); +} + +/** + * tooltip 配置 + * @param params + */ +function tooltip(params: Params): Params { + const { chart, options } = params; + const { tooltip } = options; + + if (tooltip === false) { + chart.tooltip(false); + } else { + let tooltipOptions = tooltip; + // 设置了 fields,就不进行 customItems 了; 设置 formatter 时,需要搭配 fields + if (!get(tooltip, 'fields')) { + tooltipOptions = deepAssign( + {}, + { + customItems: (items: Types.TooltipItem[]) => + items.map((item) => { + const scales = get(chart.getOptions(), 'scales'); + const nameFormatter = get(scales, ['name', 'formatter'], (v) => v); + const valueFormatter = get(scales, ['value', 'formatter'], (v) => v); + return { + ...item, + name: nameFormatter(item.data.name), + value: valueFormatter(item.data.value), + }; + }), + }, + tooltipOptions + ); + } + chart.tooltip(tooltipOptions); + } + + return params; +} + +/** + * 坐标轴, 默认关闭 + * @param params + */ +function axis(params: Params): Params { + const { chart } = params; + chart.axis(false); + return params; +} + +function adaptorInteraction(options: CirclePackingOptions): CirclePackingOptions { + const { drilldown, interactions = [] } = options; + + if (drilldown?.enabled) { + return deepAssign({}, options, { + interactions: [ + ...interactions, + { + type: 'drill-down', + cfg: { drillDownConfig: drilldown, transformData, enableDrillDown: true }, + }, + ], + }); + } + return options; +} + +/** + * 交互配置 + * @param params + * @returns + */ +function interaction(params: Params): Params { + const { chart, options } = params; + + baseInteraction({ + chart, + options: adaptorInteraction(options), + }); + + return params; +} + +/** + * 矩形树图 + * @param chart + * @param options + */ +export function adaptor(params: Params) { + return flow( + defaultOptions, + padding, + theme, + meta, + geometry, + axis, + legend, + tooltip, + interaction, + animation, + annotation() + )(params); +} diff --git a/src/plots/circle-packing/constant.ts b/src/plots/circle-packing/constant.ts new file mode 100644 index 0000000000..3f1e21a2e6 --- /dev/null +++ b/src/plots/circle-packing/constant.ts @@ -0,0 +1,31 @@ +import { CirclePackingOptions } from './types'; + +/** 默认的源字段 */ +export const RAW_FIELDS = ['x', 'y', 'r', 'name', 'value', 'path', 'depth']; + +export const DEFAULT_OPTIONS: Partial = { + // 默认按照 name 字段对颜色进行分类 + colorField: 'name', + autoFit: true, + pointStyle: { + lineWidth: 0, + stroke: '#fff', + }, + legend: false, + hierarchyConfig: { + size: [1, 1] as [number, number], // width, height + padding: 0, + }, + label: { + fields: ['name'], + layout: { + type: 'limit-in-shape', + }, + }, + tooltip: { + showMarkers: false, + showTitle: false, + }, + // 默认不可以下钻 + drilldown: { enabled: false }, +}; diff --git a/src/plots/circle-packing/index.ts b/src/plots/circle-packing/index.ts new file mode 100644 index 0000000000..b137beb0ac --- /dev/null +++ b/src/plots/circle-packing/index.ts @@ -0,0 +1,49 @@ +import { Plot } from '../../core/plot'; +import { Adaptor } from '../../core/adaptor'; +import { adaptor } from './adaptor'; +import { DEFAULT_OPTIONS } from './constant'; +import { CirclePackingOptions } from './types'; +import './interactions'; + +export type { CirclePackingOptions }; + +/** + * CirclePacking + * @usage hierarchy, proportions + */ +export class CirclePacking extends Plot { + /** + * 获取 面积图 默认配置项 + * 供外部使用 + */ + static getDefaultOptions(): Partial { + return DEFAULT_OPTIONS; + } + /** 图表类型 */ + public type: string = 'circle-packing'; + + protected getDefaultOptions() { + return CirclePacking.getDefaultOptions(); + } + + /** + * 获取适配器 + */ + protected getSchemaAdaptor(): Adaptor { + return adaptor; + } + + /** + * 覆写父类的方法 + */ + protected triggerResize() { + if (!this.chart.destroyed) { + // 首先自适应容器的宽高 + this.chart.forceFit(); // g2 内部执行 changeSize,changeSize 中执行 render(true) + this.chart.clear(); + this.execAdaptor(); // 核心:宽高更新之后计算padding + // 渲染 + this.chart.render(true); + } + } +} diff --git a/src/plots/circle-packing/interactions/index.ts b/src/plots/circle-packing/interactions/index.ts new file mode 100644 index 0000000000..ac242cd245 --- /dev/null +++ b/src/plots/circle-packing/interactions/index.ts @@ -0,0 +1,2 @@ +/** 引入 drill-down 交互 */ +import '../../../interactions/drill-down'; diff --git a/src/plots/circle-packing/types.ts b/src/plots/circle-packing/types.ts new file mode 100644 index 0000000000..ae59da30b2 --- /dev/null +++ b/src/plots/circle-packing/types.ts @@ -0,0 +1,35 @@ +import { ColorAttr, Options, SizeAttr, StyleAttr } from '../../types'; +import { DrillDownCfg } from '../../types/drill-down'; +import { HierarchyOption } from '../../utils/hierarchy/types'; + +export interface CirclePackingOptions extends Omit { + /** 数据字段 */ + readonly data?: Record; + /** 层级布局配置 */ + readonly hierarchyConfig?: Omit; + + /** 颜色字段 */ + readonly colorField?: string; + + /** 颜色配置 */ + readonly color?: ColorAttr; + + /** 大小字段 */ + readonly sizeField?: string; + + /** 源字段 */ + readonly rawFields?: string[]; + + // 暂不提供自定义 size,内部计算 + // readonly size?: SizeAttr; + + // 暂不提供 shape 配置,默认:circle. + // readonly shape?: string; + + /** 图形样式 */ + readonly pointStyle?: StyleAttr; + + // 交互 + /** 下钻交互 */ + readonly drilldown?: DrillDownCfg; +} diff --git a/src/plots/circle-packing/utils.ts b/src/plots/circle-packing/utils.ts new file mode 100644 index 0000000000..72d2726d3b --- /dev/null +++ b/src/plots/circle-packing/utils.ts @@ -0,0 +1,109 @@ +import { Types } from '@antv/g2'; +import { pack } from '../../utils/hierarchy/pack'; +import { deepAssign, pick } from '../../utils'; +import { HIERARCHY_DATA_TRANSFORM_PARAMS } from '../../interactions/actions/drill-down'; +import { normalPadding } from '../../utils/padding'; +import { CirclePackingOptions } from './types'; + +interface TransformDataOptions { + data: CirclePackingOptions['data']; + rawFields: CirclePackingOptions['rawFields']; + enableDrillDown: boolean; + hierarchyConfig: CirclePackingOptions['hierarchyConfig']; +} + +/** + * circle-packing 数据转换 + * @param options + */ +export function transformData(options: TransformDataOptions) { + const { data, hierarchyConfig, rawFields = [], enableDrillDown } = options; + + const nodes = pack(data, { + ...hierarchyConfig, + field: 'value', + as: ['x', 'y', 'r'], + }); + + const result = []; + nodes.forEach((node) => { + let path = node.data.name; + let ancestorNode = { ...node }; + while (ancestorNode.depth > 1) { + path = `${ancestorNode.parent.data?.name} / ${path}`; + ancestorNode = ancestorNode.parent; + } + + // 开启下钻,仅加载 depth <= 2 的数据 (加载两层) + if (enableDrillDown && node.depth > 2) { + return null; + } + + const nodeInfo = deepAssign({}, node.data, { + ...pick(node.data, rawFields), + path, + // 以下字段,必备: x, y, r, name, depth, height + ...node, + }); + + nodeInfo.ext = hierarchyConfig; + nodeInfo[HIERARCHY_DATA_TRANSFORM_PARAMS] = { hierarchyConfig, rawFields, enableDrillDown }; + + result.push(nodeInfo); + }); + + return result; +} + +/** + * 根据图表的 padding 和 appendPadding 计算出图表的最终 padding + * @param array + */ +export function resolveAllPadding(paddings: Types.ViewPadding[]) { + // 先把数组里的 padding 全部转换成 normal + const normalPaddings = paddings.map((item) => normalPadding(item)); + let finalPadding = [0, 0, 0, 0]; + if (normalPaddings.length > 0) { + finalPadding = finalPadding.map((item, index) => { + // 有几个 padding 数组就遍历几次,累加 + normalPaddings.forEach((d, i) => { + item += normalPaddings[i][index]; + }); + return item; + }); + } + return finalPadding; +} + +/** + * 根据传入的 padding 和 现有的 画布大小, 输出针对圆形视图布局需要的 finalPadding 以及 finalSize + * @param params + */ +export function resolvePaddingForCircle( + padding: Types.ViewPadding, + appendPadding: Types.ViewAppendPadding, + containerSize: { width: number; height: number } +) { + const tempPadding = resolveAllPadding([padding, appendPadding]); + const [top, right, bottom, left] = tempPadding; // 没设定,默认是 [0, 0, 0, 0] + const { width, height } = containerSize; + + // 有了 tempPadding 介入以后,计算出coordinate范围宽高的最小值 minSize = circle-packing的直径 + const wSize = width - (left + right); + const hSize = height - (top + bottom); + const minSize = Math.min(wSize, hSize); // circle-packing的直径 + + // 得到居中后各方向剩余的 padding + const restWidthPadding = (wSize - minSize) / 2; + const restHeightPadding = (hSize - minSize) / 2; + + const finalTop = top + restHeightPadding; + const finalRight = right + restWidthPadding; + const finalBottom = bottom + restHeightPadding; + const finalLeft = left + restWidthPadding; + + const finalPadding = [finalTop, finalRight, finalBottom, finalLeft]; + const finalSize = minSize < 0 ? 0 : minSize; // 防止为负数 + + return { finalPadding, finalSize }; +} diff --git a/src/utils/hierarchy/pack.ts b/src/utils/hierarchy/pack.ts new file mode 100644 index 0000000000..b561872e28 --- /dev/null +++ b/src/utils/hierarchy/pack.ts @@ -0,0 +1,49 @@ +import * as d3Hierarchy from 'd3-hierarchy'; +import { assign, isArray } from '@antv/util'; +import { getField, getAllNodes } from './util'; +import { HierarchyOption } from './types'; + +type Options = Omit & { as?: [string, string, string] }; + +const DEFAULT_OPTIONS: Options = { + field: 'value', + as: ['x', 'y', 'r'], + // 默认降序 + sort: (a, b) => b.value - a.value, +}; + +export function pack(data: any, options: Options): any[] { + options = assign({} as Options, DEFAULT_OPTIONS, options); + const as = options.as; + if (!isArray(as) || as.length !== 3) { + throw new TypeError('Invalid as: it must be an array with 3 strings (e.g. [ "x", "y", "r" ])!'); + } + + let field; + try { + field = getField(options); + } catch (e) { + console.warn(e); + } + + const packLayout = (data) => + d3Hierarchy.pack().size(options.size).padding(options.padding)( + d3Hierarchy + .hierarchy(data) + .sum((d) => d[field]) + .sort(options.sort) + ); + + const root = packLayout(data); + + const x = as[0]; + const y = as[1]; + const r = as[2]; + root.each((node) => { + node[x] = node.x; + node[y] = node.y; + node[r] = node.r; + }); + + return getAllNodes(root); +}