From 763d61168238725e5051ad3e3db1ee8ab16b26b5 Mon Sep 17 00:00:00 2001
From: Cheng Peng <30697592+VirusPC@users.noreply.github.com>
Date: Wed, 20 Sep 2023 10:24:19 +0800
Subject: [PATCH] feat/interval3d (#5562)
* fix(CONTRIBUTING.md): fix incorrect file path
* feat(api): interval3D
* docs(interval3D): init docs
* fix: fix the bug in the example of intervalThree.zh.md
* feat(interval3D test): beautify test plots
---
.../chart-render-3d-bar-chart-perspective.ts | 80 +++++++++++++
.../plots/api/chart-render-3d-bar-chart.ts | 78 +++++++++++++
__tests__/plots/api/index.ts | 2 +
site/.dumi/tsconfig.json | 6 +
site/docs/api/chart.zh.md | 4 +
site/docs/spec/threed/intervalThreed.en.md | 6 +
site/docs/spec/threed/intervalThreed.zh.md | 105 ++++++++++++++++++
src/lib/threed.ts | 3 +-
src/mark/index.ts | 1 +
src/mark/interval3D.ts | 100 +++++++++++++++++
src/shape/index.ts | 3 +-
src/shape/interval3D/cube.ts | 69 ++++++++++++
12 files changed, 455 insertions(+), 2 deletions(-)
create mode 100644 __tests__/plots/api/chart-render-3d-bar-chart-perspective.ts
create mode 100644 __tests__/plots/api/chart-render-3d-bar-chart.ts
create mode 100644 site/.dumi/tsconfig.json
create mode 100644 site/docs/spec/threed/intervalThreed.en.md
create mode 100644 site/docs/spec/threed/intervalThreed.zh.md
create mode 100644 src/mark/interval3D.ts
create mode 100644 src/shape/interval3D/cube.ts
diff --git a/__tests__/plots/api/chart-render-3d-bar-chart-perspective.ts b/__tests__/plots/api/chart-render-3d-bar-chart-perspective.ts
new file mode 100644
index 0000000000..30b1d7905f
--- /dev/null
+++ b/__tests__/plots/api/chart-render-3d-bar-chart-perspective.ts
@@ -0,0 +1,80 @@
+import { CameraType } from '@antv/g';
+import { Renderer as WebGLRenderer } from '@antv/g-webgl';
+import { Plugin as ThreeDPlugin, DirectionalLight } from '@antv/g-plugin-3d';
+import { Plugin as ControlPlugin } from '@antv/g-plugin-control';
+import { Runtime, extend } from '../../../src/api';
+import { corelib, threedlib } from '../../../src/lib';
+
+export function chartRender3dBarChartPerspective(context) {
+ const { container } = context;
+
+ // Create a WebGL renderer.
+ const renderer = new WebGLRenderer();
+ renderer.registerPlugin(new ThreeDPlugin());
+ renderer.registerPlugin(new ControlPlugin());
+
+ const Chart = extend(Runtime, { ...corelib(), ...threedlib() });
+ const chart = new Chart({
+ container,
+ renderer,
+ depth: 400,
+ // padding: 10,
+ });
+
+ const data: { x: string; z: string; y: number; color: number }[] = [];
+ for (let x = 0; x < 5; ++x) {
+ for (let z = 0; z < 5; ++z) {
+ data.push({
+ x: `x-${x}`,
+ z: `z-${z}`,
+ y: 10 - x - z,
+ color: Math.random() < 0.33 ? 0 : Math.random() < 0.67 ? 1 : 2,
+ });
+ }
+ }
+
+ chart
+ .interval3D()
+ .data({
+ type: 'inline',
+ value: data,
+ })
+ .encode('x', 'x')
+ .encode('y', 'y')
+ .encode('z', 'z')
+ .encode('color', 'color')
+ .encode('shape', 'cube')
+ .coordinate({ type: 'cartesian3D' })
+ .scale('x', { nice: true })
+ .scale('y', { nice: true })
+ .scale('z', { nice: true })
+ .legend(false)
+ .axis('x', { gridLineWidth: 2 })
+ .axis('y', { gridLineWidth: 2, titleBillboardRotation: -Math.PI / 2 })
+ .axis('z', { gridLineWidth: 2 })
+ .style('opacity', 0.7)
+ .style('cursor', 'pointer');
+
+ const finished = chart.render().then(() => {
+ const { canvas } = chart.getContext();
+ if (!canvas) return;
+ const camera = canvas.getCamera();
+ camera.setPerspective(0.1, 5000, 50, 1280 / 960);
+ camera.setType(CameraType.ORBITING);
+ camera.rotate(-20, -20, 0);
+
+ // Add a directional light into scene.
+ const light = new DirectionalLight({
+ style: {
+ intensity: 2.5,
+ fill: 'white',
+ direction: [-1, 0, 1],
+ },
+ });
+ canvas.appendChild(light);
+ });
+
+ return { finished };
+}
+
+chartRender3dBarChartPerspective.skip = true;
diff --git a/__tests__/plots/api/chart-render-3d-bar-chart.ts b/__tests__/plots/api/chart-render-3d-bar-chart.ts
new file mode 100644
index 0000000000..40d3230f57
--- /dev/null
+++ b/__tests__/plots/api/chart-render-3d-bar-chart.ts
@@ -0,0 +1,78 @@
+import { CameraType } from '@antv/g';
+import { Renderer as WebGLRenderer } from '@antv/g-webgl';
+import { Plugin as ThreeDPlugin, DirectionalLight } from '@antv/g-plugin-3d';
+import { Plugin as ControlPlugin } from '@antv/g-plugin-control';
+import { Runtime, extend } from '../../../src/api';
+import { corelib, threedlib } from '../../../src/lib';
+
+export function chartRender3dBarChart(context) {
+ const { container } = context;
+
+ // Create a WebGL renderer.
+ const renderer = new WebGLRenderer();
+ renderer.registerPlugin(new ThreeDPlugin());
+ renderer.registerPlugin(new ControlPlugin());
+
+ const Chart = extend(Runtime, { ...corelib(), ...threedlib() });
+ const chart = new Chart({
+ container,
+ renderer,
+ depth: 400,
+ padding: 50,
+ });
+
+ const data: { x: string; z: string; y: number; color: number }[] = [];
+ for (let x = 0; x < 5; ++x) {
+ for (let z = 0; z < 5; ++z) {
+ data.push({
+ x: `x-${x}`,
+ z: `z-${z}`,
+ y: 10 - x - z,
+ color: Math.random() < 0.33 ? 0 : Math.random() < 0.67 ? 1 : 2,
+ });
+ }
+ }
+
+ chart
+ .interval3D()
+ .data({
+ type: 'inline',
+ value: data,
+ })
+ .encode('x', 'x')
+ .encode('y', 'y')
+ .encode('z', 'z')
+ .encode('color', 'color')
+ .encode('shape', 'cube')
+ .coordinate({ type: 'cartesian3D' })
+ .scale('x', { nice: true })
+ .scale('y', { nice: true })
+ .scale('z', { nice: true })
+ .legend(false)
+ .axis('x', { gridLineWidth: 2 })
+ .axis('y', { gridLineWidth: 2, titleBillboardRotation: -Math.PI / 2 })
+ .axis('z', { gridLineWidth: 2 })
+ .style('opacity', 0.7);
+
+ const finished = chart.render().then(() => {
+ const { canvas } = chart.getContext();
+ if (!canvas) return;
+ const camera = canvas.getCamera();
+ camera.setType(CameraType.ORBITING);
+ camera.rotate(-20, -20, 0);
+
+ // Add a directional light into scene.
+ const light = new DirectionalLight({
+ style: {
+ intensity: 2.5,
+ fill: 'white',
+ direction: [-1, 0, 1],
+ },
+ });
+ canvas.appendChild(light);
+ });
+
+ return { finished };
+}
+
+chartRender3dBarChart.skip = true;
diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts
index 69937a1608..12754ac91d 100644
--- a/__tests__/plots/api/index.ts
+++ b/__tests__/plots/api/index.ts
@@ -43,6 +43,8 @@ export { chartOnTextClick } from './chart-on-text-click';
export { chartOnComponentClick } from './chart-on-component-click';
export { chartRenderEvent } from './chart-render-event';
export { chartRender3dScatterPlot } from './chart-render-3d-scatter-plot';
+export { chartRender3dBarChart } from './chart-render-3d-bar-chart';
+export { chartRender3dBarChartPerspective } from './chart-render-3d-bar-chart-perspective';
export { chartRender3dScatterPlotPerspective } from './chart-render-3d-scatter-plot-perspective';
export { chartRender3dScatterPlotLegend } from './chart-render-3d-scatter-plot-legend';
export { chartRender3dLinePlot } from './chart-render-3d-line-plot';
diff --git a/site/.dumi/tsconfig.json b/site/.dumi/tsconfig.json
new file mode 100644
index 0000000000..79711a82bb
--- /dev/null
+++ b/site/.dumi/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../tsconfig.json",
+ "include": [
+ "**/*"
+ ]
+}
\ No newline at end of file
diff --git a/site/docs/api/chart.zh.md b/site/docs/api/chart.zh.md
index 3c4fcca131..6164466551 100644
--- a/site/docs/api/chart.zh.md
+++ b/site/docs/api/chart.zh.md
@@ -199,6 +199,10 @@ chart.render();
添加 point3D 图形,具体见 [3d](/spec/threed/point-threed)。
+### `chart.interval3D`
+
+添加 interval3D 图形,具体见 [3d](/spec/threed/interval-threed)。
+
### `chart.line3D`
添加 line3D 图形,具体见 [3d](/spec/threed/line-threed)。
diff --git a/site/docs/spec/threed/intervalThreed.en.md b/site/docs/spec/threed/intervalThreed.en.md
new file mode 100644
index 0000000000..0892beb548
--- /dev/null
+++ b/site/docs/spec/threed/intervalThreed.en.md
@@ -0,0 +1,6 @@
+---
+title: interval3D
+order: 3
+---
+
+
diff --git a/site/docs/spec/threed/intervalThreed.zh.md b/site/docs/spec/threed/intervalThreed.zh.md
new file mode 100644
index 0000000000..caaa42c711
--- /dev/null
+++ b/site/docs/spec/threed/intervalThreed.zh.md
@@ -0,0 +1,105 @@
+---
+title: interval3D
+order: 3
+---
+
+主要用于绘制 3D 条形图。
+
+## 开始使用
+
+首先需要使用 [@antv/g-webgl](https://g.antv.antgroup.com/api/renderer/webgl) 作为渲染器并注册以下两个插件:
+
+- [g-plugin-3d](https://g.antv.antgroup.com/plugins/3d) 提供 3D 场景下的几何、材质和光照
+- [g-plugin-control](https://g.antv.antgroup.com/plugins/control) 提供 3D 场景下的相机交互
+
+然后设置 z 通道、scale 和 z 坐标轴,最后在场景中添加光源。
+
+```js | ob
+(() => {
+ // Create a WebGL renderer.
+ const renderer = new gWebgl.Renderer();
+ renderer.registerPlugin(new gPluginControl.Plugin());
+ renderer.registerPlugin(new gPlugin3d.Plugin());
+
+ const Chart = G2.extend(G2.Runtime, { ...G2.corelib(), ...G2.threedlib() });
+
+ // 初始化图表实例
+ const chart = new Chart({
+ renderer,
+ width: 500,
+ height: 500,
+ depth: 400,
+ });
+
+ const data = [];
+ for (let x = 0; x < 5; ++x) {
+ for (let z = 0; z < 5; ++z) {
+ data.push({
+ x: `x-${x}`,
+ z: `z-${z}`,
+ y: 10 - x - z,
+ color: Math.random() < 0.33 ? 0 : Math.random() < 0.67 ? 1 : 2,
+ });
+ }
+ }
+
+ chart
+ .interval3D()
+ .data({
+ type: 'inline',
+ value: data,
+ })
+ .encode('x', 'x')
+ .encode('y', 'y')
+ .encode('z', 'z')
+ .encode('color', 'color')
+ .encode('shape', 'cube')
+ .coordinate({ type: 'cartesian3D' })
+ .scale('x', { nice: true })
+ .scale('y', { nice: true })
+ .scale('z', { nice: true })
+ .legend(false)
+ .axis('x', { gridLineWidth: 2 })
+ .axis('y', { gridLineWidth: 2, titleBillboardRotation: -Math.PI / 2 })
+ .axis('z', { gridLineWidth: 2 });
+
+ chart.render().then(() => {
+ const { canvas } = chart.getContext();
+ const camera = canvas.getCamera();
+ camera.setPerspective(0.1, 5000, 80, 1280 / 960);
+ camera.setType(CameraType.ORBITING);
+ camera.rotate(-20, -20, 0);
+
+ // Add a directional light into scene.
+ const light = new DirectionalLight({
+ style: {
+ intensity: 2.5,
+ fill: 'white',
+ direction: [-1, 0, 1],
+ },
+ });
+ canvas.appendChild(light);
+ });
+
+ return chart.getContainer();
+})();
+```
+
+更多的案例,可以查看[图表示例](/examples)页面。
+
+## 选项
+
+目前 interval3D 有以下一个内置 shape 图形:
+
+| 图形 | 描述 | 示例 |
+| ------ | ---------- | ---- |
+| cube | 绘制立方体 | |
+
+### cube
+
+| 属性 | 描述 | 类型 | 默认值 |
+| ------- | --------------------------------------------- | ------------------------------ | --------- |
+| fill | 图形的填充色 | `string` \| `Function` | - |
+| opacity | 图形的整体透明度 | `number` \| `Function` | - |
+| cursor | 鼠标样式。同 css 的鼠标样式,默认 'default'。 | `string` \| `Function` | 'default' |
+
diff --git a/src/lib/threed.ts b/src/lib/threed.ts
index 4c55355361..94bb307bd9 100644
--- a/src/lib/threed.ts
+++ b/src/lib/threed.ts
@@ -1,6 +1,6 @@
import { Cartesian3D } from '../coordinate';
import { AxisZ } from '../component';
-import { Point3D, Line3D } from '../mark';
+import { Point3D, Line3D, Interval3D } from '../mark';
export function threedlib() {
return {
@@ -8,5 +8,6 @@ export function threedlib() {
'component.axisZ': AxisZ,
'mark.point3D': Point3D,
'mark.line3D': Line3D,
+ 'mark.interval3D': Interval3D,
} as const;
}
diff --git a/src/mark/index.ts b/src/mark/index.ts
index 05160b8a7d..8a47de62e8 100644
--- a/src/mark/index.ts
+++ b/src/mark/index.ts
@@ -4,6 +4,7 @@ export { Line } from './line';
export { Line3D } from './line3D';
export { Point } from './point';
export { Point3D } from './point3D';
+export { Interval3D } from './interval3D';
export { Text } from './text';
export { Cell } from './cell';
export { Area } from './area';
diff --git a/src/mark/interval3D.ts b/src/mark/interval3D.ts
new file mode 100644
index 0000000000..f1db9468ac
--- /dev/null
+++ b/src/mark/interval3D.ts
@@ -0,0 +1,100 @@
+import { Coordinate3D } from '@antv/coord';
+import { Band } from '@antv/scale';
+import { MarkComponent as MC, Vector3 } from '../runtime';
+import { PointMark } from '../spec';
+import {
+ MaybeZeroX,
+ MaybeZeroY,
+ MaybeZeroZ,
+ MaybeSize,
+ MaybeZeroY1,
+} from '../transform';
+import { Sphere, IntervalCube } from '../shape';
+import {
+ baseGeometryChannels,
+ basePostInference,
+ basePreInference,
+ tooltip3d,
+} from './utils';
+
+export type PointOptions = Omit;
+
+/**
+ * Convert value for each channel to rect shapes.
+ * return two 3D points, which represents the bounding box of a rect.
+ */
+export const Interval3D: MC = (options) => {
+ return (index, scale, value, coordinate) => {
+ const {
+ x: X,
+ y: Y,
+ y1: Y1,
+ z: Z,
+ size: SZ,
+ dx: DX,
+ dy: DY,
+ dz: DZ,
+ } = value;
+
+ // The scales for x and series channels must be band scale.
+ const x = scale.x as Band;
+ const z = scale.x as Band;
+ const [width, height, depth] = (
+ coordinate as unknown as Coordinate3D
+ ).getSize();
+ const x1x2 = (x: number, w: number, i: number) => [x, x + w];
+
+ // Calc the points of bounding box for the interval.
+ // They are start from left-top corner in clock wise order.
+ // TODO: series support
+ const P = Array.from(index, (i) => {
+ const groupWidthX = bandWidth(x, X[i]);
+ const groupWidthZ = bandWidth(z, Z[i]);
+ const x0 = +X[i];
+ const z0 = +Z[i];
+ const [x1, x2] = x1x2(x0, groupWidthX, i);
+ const [z1, z2] = x1x2(z0, groupWidthZ, i);
+ const y1 = +Y[i];
+ const y2 = +Y1[i];
+
+ const p1 = [x1, y1, z1];
+ const p2 = [x2, y2, z2];
+
+ return [
+ (coordinate as unknown as Coordinate3D).map([...p1]),
+ (coordinate as unknown as Coordinate3D).map([...p2]),
+ ];
+ });
+ return [index, P];
+ };
+};
+
+function bandWidth(scale: Band, xz: any): number {
+ return scale.getBandWidth(scale.invert(xz));
+}
+
+const shape = {
+ cube: IntervalCube,
+};
+
+Interval3D.props = {
+ defaultShape: 'cube',
+ defaultLabelShape: 'label',
+ composite: false,
+ shape,
+ channels: [
+ ...baseGeometryChannels({ shapes: Object.keys(shape) }),
+ { name: 'x', scale: 'band', required: true },
+ { name: 'z', scale: 'band', required: true },
+ { name: 'y', required: true },
+ { name: 'series', scale: 'band' },
+ { name: 'size' },
+ ],
+ preInference: [
+ ...basePreInference(),
+ { type: MaybeZeroX },
+ { type: MaybeZeroY1 },
+ { type: MaybeZeroZ },
+ ],
+ postInference: [...basePostInference(), { type: MaybeSize }, ...tooltip3d()],
+};
diff --git a/src/shape/index.ts b/src/shape/index.ts
index 02f09af52f..9dce519918 100644
--- a/src/shape/index.ts
+++ b/src/shape/index.ts
@@ -32,6 +32,7 @@ export { Triangle as PointTriangle } from './point/triangle';
export { TriangleDown as PointTriangleDown } from './point/triangleDown';
export { Sphere } from './point3D/sphere';
export { Cube } from './point3D/cube';
+export { Cube as IntervalCube } from './interval3D/cube';
export { Vector as VectorShape } from './vector/vector';
export { Text as TextShape } from './text/text';
export { Badge as TextBadge } from './text/badge';
@@ -65,7 +66,7 @@ export { Liquid as LiquidShape } from './liquid/liquid';
export type { RectOptions as IntervalShapeOptions } from './interval/rect';
export type { HollowOptions as IntervalHollowOptions } from './interval/hollow';
export type { FunnelOptions as IntervalFunnelOptions } from './interval/funnel';
-export type { PyramidOptions as IntervalPyramidOptions } from './interval/pyramid';
+export type { PyramidOptions as IntervalPyline3DramidOptions } from './interval/pyramid';
export type { LineOptions as LineShapeOptions } from './line/line';
export type { SmoothOptions as LineSmoothOptions } from './line/smooth';
export type { HVOptions as LineHVOptions } from './line/hv';
diff --git a/src/shape/interval3D/cube.ts b/src/shape/interval3D/cube.ts
new file mode 100644
index 0000000000..93a4425cd4
--- /dev/null
+++ b/src/shape/interval3D/cube.ts
@@ -0,0 +1,69 @@
+import { MeshLambertMaterial, CubeGeometry, Mesh } from '@antv/g-plugin-3d';
+import { applyStyle, getOrigin, toOpacityKey } from '../utils';
+import { ShapeComponent as SC, Vector3 } from '../../runtime';
+import { select } from '../../utils/selection';
+
+export type CubeOptions = Record;
+
+/**
+ * @see https://g.antv.antgroup.com/api/3d/geometry#cubegeometry
+ */
+export const Cube: SC = (options, context) => {
+ // Render border only when colorAttribute is stroke.
+ const { ...style } = options;
+
+ // @ts-ignore
+ if (!context.cubeGeometry) {
+ const renderer = context.canvas.getConfig().renderer;
+ const plugin = renderer.getPlugin('device-renderer');
+ const device = plugin.getDevice();
+ // create a cube geometry
+ // @ts-ignore
+ context.cubeGeometry = new CubeGeometry(device, {
+ width: 1,
+ height: 1,
+ depth: 1,
+ });
+ // create a material with Lambert lighting model
+ // @ts-ignore
+ context.cubeMaterial = new MeshLambertMaterial(device);
+ }
+
+ return (_points, value, defaults) => {
+ const points = _points as unknown as Vector3[];
+ const { color: defaultColor } = defaults;
+ const { color = defaultColor, transform, opacity } = value;
+ const [cx, cy, cz] = getOrigin(points);
+
+ const width = Math.abs(points[1][0] - points[0][0]);
+ const height = Math.abs(points[1][1] - points[0][1]);
+ const depth = Math.abs(points[1][2] - points[0][2]);
+
+ const cube = new Mesh({
+ style: {
+ x: cx,
+ y: cy,
+ z: cz,
+ // @ts-ignore
+ geometry: context.cubeGeometry,
+ // @ts-ignore
+ material: context.cubeMaterial,
+ },
+ });
+ cube.setOrigin(0, 0, 0);
+ cube.scale([width, height, depth]);
+
+ const selection = select(cube)
+ .call(applyStyle, defaults)
+ .style('fill', color)
+ .style('transform', transform)
+ .style(toOpacityKey(options), opacity)
+ .call(applyStyle, style)
+ .node();
+ return selection;
+ };
+};
+
+Cube.props = {
+ defaultMarker: 'cube',
+};