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', +};