diff --git a/__tests__/main.ts b/__tests__/main.ts index 42c7d698d1..951dcb3a19 100644 --- a/__tests__/main.ts +++ b/__tests__/main.ts @@ -1,8 +1,6 @@ import { Canvas } from '@antv/g'; import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Plugin as DragAndDropPlugin } from '@antv/g-plugin-dragndrop'; -import { Plugin as ControlPlugin } from '@antv/g-plugin-control'; -import { Plugin as ThreeDPlugin } from '@antv/g-plugin-3d'; import { Renderer as SVGRenderer } from '@antv/g-svg'; import { Renderer as WebGLRenderer } from '@antv/g-webgl'; import { stdlib, render } from '../src'; @@ -151,10 +149,6 @@ function createSpecRender(object) { renderer.registerPlugin( new DragAndDropPlugin({ dragstartDistanceThreshold: 1 }), ); - if (selectRenderer.value === 'webgl') { - renderer.registerPlugin(new ControlPlugin()); - renderer.registerPlugin(new ThreeDPlugin()); - } canvas = new Canvas({ container: document.createElement('div'), width, diff --git a/__tests__/plots/api/chart-render-3d-scatter-plot-perspective.ts b/__tests__/plots/api/chart-render-3d-scatter-plot-perspective.ts new file mode 100644 index 0000000000..0cfc969a6f --- /dev/null +++ b/__tests__/plots/api/chart-render-3d-scatter-plot-perspective.ts @@ -0,0 +1,65 @@ +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 chartRender3dScatterPlotPerspective(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, + theme: 'classic', + renderer, + depth: 400, + }); + + chart + .point3D() + .data({ + type: 'fetch', + value: 'data/cars2.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('size', 'Origin') + .encode('color', 'Cylinders') + .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 }); + + const finished = chart.render().then(() => { + const { canvas } = chart.getContext(); + const camera = canvas!.getCamera(); + camera.setPerspective(0.1, 5000, 45, 500 / 500); + camera.setType(CameraType.ORBITING); + + // Add a directional light into scene. + const light = new DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas!.appendChild(light); + }); + + return { finished }; +} + +chartRender3dScatterPlotPerspective.skip = true; diff --git a/__tests__/plots/api/chart-render-3d-scatter-plot.ts b/__tests__/plots/api/chart-render-3d-scatter-plot.ts new file mode 100644 index 0000000000..6046b75f91 --- /dev/null +++ b/__tests__/plots/api/chart-render-3d-scatter-plot.ts @@ -0,0 +1,65 @@ +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 chartRender3dScatterPlot(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, + theme: 'classic', + renderer, + depth: 400, + }); + + chart + .point3D() + .data({ + type: 'fetch', + value: 'data/cars2.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('size', 'Origin') + .encode('color', 'Cylinders') + .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 }); + + const finished = chart.render().then(() => { + const { canvas } = chart.getContext(); + 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 }; +} + +chartRender3dScatterPlot.skip = true; diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts index 6ea8ebf4bd..3ff7aad970 100644 --- a/__tests__/plots/api/index.ts +++ b/__tests__/plots/api/index.ts @@ -41,3 +41,5 @@ export { chartChangeDataLegend } from './chart-change-data-legend'; export { chartTooltipMultiChart } from './chart-tooltip-multi-chart'; export { chartOnTextClick } from './chart-on-text-click'; export { chartRenderEvent } from './chart-render-event'; +export { chartRender3dScatterPlot } from './chart-render-3d-scatter-plot'; +export { chartRender3dScatterPlotPerspective } from './chart-render-3d-scatter-plot-perspective'; diff --git a/__tests__/unit/coordinate/index.spec.ts b/__tests__/unit/coordinate/index.spec.ts index 433fe91b7b..ba5fec4f84 100644 --- a/__tests__/unit/coordinate/index.spec.ts +++ b/__tests__/unit/coordinate/index.spec.ts @@ -4,6 +4,7 @@ import { Transpose, Parallel, Fisheye, + Cartesian3D, } from '../../../src/coordinate'; describe('coordinate', () => { @@ -50,4 +51,8 @@ describe('coordinate', () => { it('Fisheye({...}) returns expected coordinate transformations', () => { expect(Fisheye({})).toEqual([['fisheye', 0, 0, 2, 2, false]]); }); + + it('Cartesian3D({...}) returns expected coordinate transformations', () => { + expect(Cartesian3D({})).toEqual([['cartesian3D']]); + }); }); diff --git a/__tests__/unit/lib/geo.spec.ts b/__tests__/unit/lib/geo.spec.ts index af82f6335b..f6fb8d039c 100644 --- a/__tests__/unit/lib/geo.spec.ts +++ b/__tests__/unit/lib/geo.spec.ts @@ -2,7 +2,7 @@ import { geolib } from '../../../src/lib'; import { GeoView, GeoPath } from '../../../src/composition'; describe('geolib', () => { - it('geolib() should returns expected geo compoents.', () => { + it('geolib() should returns expected geo components.', () => { expect(geolib()).toEqual({ 'composition.geoView': GeoView, 'composition.geoPath': GeoPath, diff --git a/__tests__/unit/lib/threed.spec.ts b/__tests__/unit/lib/threed.spec.ts new file mode 100644 index 0000000000..f111745a2a --- /dev/null +++ b/__tests__/unit/lib/threed.spec.ts @@ -0,0 +1,14 @@ +import { threedlib } from '../../../src/lib'; +import { Cartesian3D } from '../../../src/coordinate'; +import { AxisZ } from '../../../src/component'; +import { Point3D } from '../../../src/mark'; + +describe('threedlib', () => { + it('threedlib() should returns expected threed components.', () => { + expect(threedlib()).toEqual({ + 'coordinate.cartesian3D': Cartesian3D, + 'component.axisZ': AxisZ, + 'mark.point3D': Point3D, + }); + }); +}); diff --git a/site/.dumi/global.ts b/site/.dumi/global.ts index 9897fc3e26..19c7f87c14 100644 --- a/site/.dumi/global.ts +++ b/site/.dumi/global.ts @@ -22,8 +22,11 @@ if (window) { window as any ).gPluginRoughCanvasRenderer = require('@antv/g-plugin-rough-canvas-renderer'); (window as any).gPluginA11y = require('@antv/g-plugin-a11y'); + (window as any).gPluginControl = require('@antv/g-plugin-control'); + (window as any).gPlugin3d = require('@antv/g-plugin-3d'); (window as any).gSvg = require('@antv/g-svg'); (window as any).gWebgl = require('@antv/g-webgl'); + (window as any).g = require('@antv/g'); (window as any).fecha = require('fecha'); (window as any).React = require('react'); (window as any).dataSet = require('@antv/data-set'); @@ -113,8 +116,10 @@ function extendG2(g2) { 'frame', 'x', 'y', + 'z', 'width', 'height', + 'depth', ...dimensionKeys('margin'), ...dimensionKeys('padding'), ...dimensionKeys('inset'), diff --git a/site/.dumirc.ts b/site/.dumirc.ts index 72b4046e59..776f606dbd 100644 --- a/site/.dumirc.ts +++ b/site/.dumirc.ts @@ -215,6 +215,14 @@ export default defineConfig({ }, order: 14, }, + { + slug: 'spec/threed', + title: { + zh: '3D 图表 - 3D Charts', + en: '3D', + }, + order: 15, + }, { slug: 'spec/theme', title: { @@ -329,6 +337,14 @@ export default defineConfig({ }, icon: 'other', }, + { + slug: 'threed', + title: { + zh: '3D 可视化', + en: '3D Charts', + }, + icon: 'other', + }, { slug: 'interesting', title: { diff --git a/site/docs/api/chart.zh.md b/site/docs/api/chart.zh.md index b79d9abf6a..0603a12dea 100644 --- a/site/docs/api/chart.zh.md +++ b/site/docs/api/chart.zh.md @@ -37,6 +37,7 @@ chart.render(); | container | 指定 chart 绘制的 DOM,可以传入 DOM id,也可以直接传入 dom 实例 | `string \| HTMLElement` | | | width | 图表宽度 | `number` | 640 | | height | 图表高度 | `number` | 480 | +| depth | 图表深度,在 3D 图表中使用 | `number` | 0 | | renderer | 指定渲染引擎,默认使用 canvas。 | | | | plugins | 指定渲染时使用的插件 ,具体见 [plugin](/api/plugin/rough) | `any[]` | | | autoFit | 图表是否自适应容器宽高,默认为 `false`,用户需要手动设置 `width` 和 `height`。
当 `autoFit: true` 时,会自动取图表容器的宽高,如果用户设置了 `height`,那么会以用户设置的 `height` 为准。 | `boolean` | false | @@ -109,7 +110,9 @@ chart.render(); 添加 rangeY 图形,具体见 [mark](/spec/mark/range-y)。 ### `chart.connector` + + 添加 connector 图形,具体见 [mark](/spec/mark/connector)。 ### `chart.sankey` @@ -192,6 +195,10 @@ chart.render(); 添加 timingKeyframe 图形,具体见 [composition](/spec/composition/timing-keyframe)。 +### `chart.point3D` + +添加 point3D 图形,具体见 [3d](/spec/threed/point-threed)。 + ## 设置属性 ### `chart.width` @@ -215,11 +222,15 @@ chart.render(); 设置图形的数据,支持多种数据来源和数据变换,具体见 [data](/spec/data/overview)。 ### `chart.encode` + + 设置图形每个通道的字段名称,具体见 [encode](/api/encode/overview)。 ### `chart.scale` + + 设置图形每个通道的比例尺,具体见 [scale](/spec/overview#scale)。 ### `chart.legend` @@ -243,7 +254,9 @@ chart.render(); 设置图形的标签,具体见 [label](/spec/label/overview)。 ### `chart.style` + + 设置图形的样式,具体见 [style](/spec/common/style)。 ### `chart.theme` @@ -251,7 +264,9 @@ chart.render(); 设置图形的主题,具体见 [theme](/spec/theme/academy)。 ### `chart.labelTransform` + + 设置图形的 labelTransform,具体见 [label](/spec/label/overview) ## 渲染图表 diff --git a/site/docs/api/overview.zh.md b/site/docs/api/overview.zh.md index dce66c2369..1b7c369d8b 100644 --- a/site/docs/api/overview.zh.md +++ b/site/docs/api/overview.zh.md @@ -46,6 +46,7 @@ order: 1 - [chart.**timingKeyframe**](/api/chart#charttimingkeyframe) - 添加 timingKeyframe 到该图表。 - [chart.**geoView**](/api/chart#chartgeoview) - 添加 geoView 到该图表。 - [chart.**geoPath**](/api/chart#chartgeopath) - 添加 geoPath 到该图表。 +- [chart.**point3D**](/api/chart#chartpoint3d) - 添加 point3D 标记到该视图。 ### 设置属性 diff --git a/site/docs/manual/core/coordinate.zh.md b/site/docs/manual/core/coordinate.zh.md index fda23b991f..0e9a73a36b 100644 --- a/site/docs/manual/core/coordinate.zh.md +++ b/site/docs/manual/core/coordinate.zh.md @@ -327,3 +327,11 @@ chart.area(): return chart.getContainer(); })(); ``` + +## 3D 坐标系 + +目前我们仅支持 `cartesian3D` 坐标系: + +```ts +chart.coordinate({ type: 'cartesian3D' }); +``` diff --git a/site/docs/manual/core/encode.zh.md b/site/docs/manual/core/encode.zh.md index 178ebeea9b..e492bf3f57 100644 --- a/site/docs/manual/core/encode.zh.md +++ b/site/docs/manual/core/encode.zh.md @@ -244,6 +244,7 @@ chart.encode('y', 'end').encode('y1', 'start'); - **x** - x 位置 - **y** - y 位置 +- **z** - z 位置 - **color** - 颜色,填充色或者边框色,由形状决定 - **opacity** - 透明度,填充透明度或者边框透明度,由样式决定 - **shape** - 形状 diff --git a/site/docs/manual/extra-topics/3d-charts.en.md b/site/docs/manual/extra-topics/3d-charts.en.md new file mode 100644 index 0000000000..195d7d6f86 --- /dev/null +++ b/site/docs/manual/extra-topics/3d-charts.en.md @@ -0,0 +1,6 @@ +--- +title: Use 3D Charts +order: 11 +--- + + diff --git a/site/docs/manual/extra-topics/3d-charts.zh.md b/site/docs/manual/extra-topics/3d-charts.zh.md new file mode 100644 index 0000000000..92863f0391 --- /dev/null +++ b/site/docs/manual/extra-topics/3d-charts.zh.md @@ -0,0 +1,312 @@ +--- +title: 绘制 3D 图表 +order: 11 +--- + +以 3D 散点图为例,创建图表需要以下步骤: + +- 创建 WebGL 渲染器和插件 +- 扩展 threedlib +- 设置 z 通道、比例尺和坐标轴 +- 在场景中设置相机 +- 添加光源 +- 使用相机交互 + +我们暂不支持图例。 + +## 创建 WebGL 渲染器和插件 + +首先安装依赖: + +```bash +$ npm install @antv/g-webgl @antv/g-plugin-3d @antv/g-plugin-control --save +``` + +然后使用 [@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 场景下的相机交互 + +```ts +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'; + +const renderer = new WebGLRenderer(); +renderer.registerPlugin(new ThreeDPlugin()); +renderer.registerPlugin(new ControlPlugin()); +``` + +## 扩展 threedlib + +由于 3D 相关的功能代码体积巨大,我们将其分离到 [threedlib](/manual/extra-topics/bundle#g2threedlib) 中,在运行时扩展它并自定义 Chart 对象: + +```ts +import { Runtime, corelib, threedlib, extend } from '@antv/g2'; + +const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); +``` + +## 设置 z 通道、比例尺和坐标轴 + +在创建 Chart 时通过 `depth` 指定深度: + +```ts +const chart = new Chart({ + container: 'container', + theme: 'classic', + renderer, + depth: 400, +}); +``` + +我们使用 [point3D](/spec/threed/point-threed) Mark 并选择 cube 作为 shape 进行绘制。 +随后设置 z 通道、比例尺和坐标轴。 + +```ts +chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('size', 'Origin') + .encode('color', 'Cylinders') + .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 }); +``` + +## 设置相机 + +在 3D 场景中我们可以使用正交或者透视投影,在首次渲染完成后可以从 Chart 上下文中获取相机。随后可以使用 G 提供的[相机 API](https://g.antv.antgroup.com/api/camera/intro) 完成投影模式、相机类型的设置。在下面的例子中,我们使用了透视投影, + +```ts +chart.render().then(() => { + const { canvas } = chart.getContext(); + const camera = canvas.getCamera(); // 获取相机 + + camera.setPerspective(0.1, 5000, 45, 500 / 500); + camera.setType(CameraType.ORBITING); +}); +``` + +效果如下: + +```js | ob { pin: false } +(() => { + 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({ + theme: 'classic', + renderer, + width: 500, + height: 500, + depth: 400, + }); + + chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('color', 'Cylinders') + .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, 45, 500 / 500); + camera.setType(g.CameraType.ORBITING); + + // Add a directional light into scene. + const light = new gPlugin3d.DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas.appendChild(light); + }); + + return chart.getContainer(); +})(); +``` + +我们还可以让相机固定视点进行一定角度的旋转,这里使用了 [rotate](https://g.antv.antgroup.com/api/camera/action#rotate): + +```ts +camera.rotate(-20, -20, 0); +``` + +```js | ob { pin: false } +(() => { + 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({ + theme: 'classic', + renderer, + width: 500, + height: 500, + depth: 400, + }); + + chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('color', 'Cylinders') + .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.setType(g.CameraType.ORBITING); + camera.rotate(-20, -20, 0); + + // Add a directional light into scene. + const light = new gPlugin3d.DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas.appendChild(light); + }); + + return chart.getContainer(); +})(); +``` + +## 添加光源 + +材质需要配合光源呈现出某种“立体感”。这里我们使用 G 提供的[平行光源](https://g.antv.antgroup.com/api/3d/light): + +```ts +import { DirectionalLight } from '@antv/g-plugin-3d'; + +const light = new DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, +}); +canvas.appendChild(light); +``` + +我们可以通过 `intensity` 增大光源的强度: + +```js | ob { pin: false } +(() => { + 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({ + theme: 'classic', + renderer, + width: 500, + height: 500, + depth: 400, + }); + + chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('color', 'Cylinders') + .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, 45, 500 / 500); + camera.setType(g.CameraType.ORBITING); + + // Add a directional light into scene. + const light = new gPlugin3d.DirectionalLight({ + style: { + intensity: 5, + fill: 'white', + direction: [0, 0, 1], + }, + }); + canvas.appendChild(light); + }); + + return chart.getContainer(); +})(); +``` + +## 使用相机交互 + +3D 场景下的交互和 2D 场景有很大的不同,[g-plugin-control](https://g.antv.antgroup.com/plugins/control) 提供了 3D 场景下基于相机的交互。当我们拖拽画布时,会控制相机绕视点进行旋转操作,而鼠标滚轮的缩放会让相机进行 dolly 操作。 + +需要注意的是缩放操作在正交投影下是没有效果的,但旋转操作依然有效。 diff --git a/site/docs/manual/extra-topics/bundle.zh.md b/site/docs/manual/extra-topics/bundle.zh.md index 455973b21a..6b9f7e1f54 100644 --- a/site/docs/manual/extra-topics/bundle.zh.md +++ b/site/docs/manual/extra-topics/bundle.zh.md @@ -253,7 +253,7 @@ chart.auto(); // Auto Mark > 开发中,预计 10 月底上线 -返回 3D 分析库,提供 3D 可视化的能力。该 library 不会包含在 [G2.stdlib](#g2stdlib) 里面,同样不能单独使用,需要配合 [G2.corelib](#g2corelib) 使用。 +返回 3D 分析库,提供 3D 可视化的能力。该 library 不会包含在 [G2.stdlib](#g2stdlib) 里面,同样不能单独使用,需要配合 [G2.corelib](#g2corelib) 使用。[示例](/manual/extra-topics/3d-charts) ```js import { Runtime, extend, threedlib, corelib } from '@antv/g2'; @@ -267,9 +267,10 @@ const Chart = extend(Runtime, { const chart = new Chart({ theme: 'classic', renderer: new Renderer(), //使用 webgl 渲染器 + depth: 400, // 设置深度 }); -chart.interval3d(); +chart.point3D(); ``` ## 未来工作 diff --git a/site/docs/manual/introduction/why-g2.zh.md b/site/docs/manual/introduction/why-g2.zh.md index 56b99d08bd..ed80f9cc78 100644 --- a/site/docs/manual/introduction/why-g2.zh.md +++ b/site/docs/manual/introduction/why-g2.zh.md @@ -686,7 +686,7 @@ import { Runtime, corelib, extend } from '@antv/g2'; // 基于 corelib 对 Runtime 进行扩展 // 1. 增加类型(如果使用的 TypeScript) // 2. 增加 Mark -const Chart = extend(Runtime, corelib); +const Chart = extend(Runtime, { ...corelib() }); const chart = new Chart({ container: 'container' }); diff --git a/site/docs/spec/coordinate/cartesian3D.en.md b/site/docs/spec/coordinate/cartesian3D.en.md new file mode 100644 index 0000000000..4865c88f91 --- /dev/null +++ b/site/docs/spec/coordinate/cartesian3D.en.md @@ -0,0 +1,6 @@ +--- +title: cartesian3D +order: 5 +--- + + diff --git a/site/docs/spec/coordinate/cartesian3D.zh.md b/site/docs/spec/coordinate/cartesian3D.zh.md new file mode 100644 index 0000000000..6062942265 --- /dev/null +++ b/site/docs/spec/coordinate/cartesian3D.zh.md @@ -0,0 +1,50 @@ +--- +title: cartesian3D +order: 5 +--- + +在 2D 笛卡尔坐标系基础上,通过增加 Z 轴扩展而来。[示例](/manual/extra-topics/3d-charts) + +## 开始使用 + +cartesian3D + +```js +import { Runtime, corelib, threedlib, extend } from '@antv/g2'; + +const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); + +const chart = new Chart({ + container: 'container', + theme: 'classic', + renderer, + depth: 400, +}); + +chart.coordinate({ + type: 'cartesian3D', +}); + +chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('size', 'Origin') + .encode('color', 'Cylinders') + .encode('shape', 'cube') + .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(); +``` diff --git a/site/docs/spec/threed/pointThreed.en.md b/site/docs/spec/threed/pointThreed.en.md new file mode 100644 index 0000000000..a7740d28a0 --- /dev/null +++ b/site/docs/spec/threed/pointThreed.en.md @@ -0,0 +1,6 @@ +--- +title: point3D +order: 1 +--- + + diff --git a/site/docs/spec/threed/pointThreed.zh.md b/site/docs/spec/threed/pointThreed.zh.md new file mode 100644 index 0000000000..584a7a0450 --- /dev/null +++ b/site/docs/spec/threed/pointThreed.zh.md @@ -0,0 +1,156 @@ +--- +title: point3D +order: 1 +--- + +主要用于绘制 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 +(() => { + 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({ + theme: 'classic', + renderer, + width: 500, + height: 500, + depth: 400, + }); + + chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('color', 'Cylinders') + .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, 45, 500 / 500); + camera.setType(g.CameraType.ORBITING); + + // Add a directional light into scene. + const light = new gPlugin3d.DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas.appendChild(light); + }); + + return chart.getContainer(); +})(); +``` + +更多的案例,可以查看[图表示例](/examples)页面。 + +## 选项 + +目前 point3D 有以下几个内置 shape 图形: + +| 图形 | 描述 | 示例 | +| ------ | ---------- | ---- | +| cube | 绘制立方体 | | +| sphere | 绘制球体 | | + +使用球体效果如下: + +```js | ob { pin: false } +(() => { + 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({ + theme: 'classic', + renderer, + width: 500, + height: 500, + depth: 400, + }); + + chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('color', 'Cylinders') + .encode('shape', 'sphere') + .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, 45, 500 / 500); + camera.setType(g.CameraType.ORBITING); + + // Add a directional light into scene. + const light = new gPlugin3d.DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas.appendChild(light); + }); + + return chart.getContainer(); +})(); +``` + +### cube + +| 属性 | 描述 | 类型 | 默认值 | +| ------- | --------------------------------------------- | ------------------------------ | --------- | +| fill | 图形的填充色 | `string` \| `Function` | - | +| opacity | 图形的整体透明度 | `number` \| `Function` | - | +| cursor | 鼠标样式。同 css 的鼠标样式,默认 'default'。 | `string` \| `Function` | 'default' | + +其他的 point3D 图形配置项和 `cube` 一致。 diff --git a/site/examples/3d/scatter/demo/meta.json b/site/examples/3d/scatter/demo/meta.json new file mode 100644 index 0000000000..42dd2cc544 --- /dev/null +++ b/site/examples/3d/scatter/demo/meta.json @@ -0,0 +1,32 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "orthographic-projection.ts", + "title": { + "zh": "正交投影", + "en": "Orthographic projection" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*7MdMQY-QksEAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "perspective-projection.ts", + "title": { + "zh": "透视投影", + "en": "Perspective projection" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*KNCUQqzw2JsAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "sphere-shape.ts", + "title": { + "zh": "球体", + "en": "Sphere" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*KNCUQqzw2JsAAAAAAAAAAAAADmJ7AQ/original" + } + ] +} diff --git a/site/examples/3d/scatter/demo/orthographic-projection.ts b/site/examples/3d/scatter/demo/orthographic-projection.ts new file mode 100644 index 0000000000..2583d096e4 --- /dev/null +++ b/site/examples/3d/scatter/demo/orthographic-projection.ts @@ -0,0 +1,56 @@ +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, corelib, threedlib, extend } from '@antv/g2'; + +// Create a WebGL renderer. +const renderer = new WebGLRenderer(); +renderer.registerPlugin(new ThreeDPlugin()); +renderer.registerPlugin(new ControlPlugin()); + +// Customize our own Chart with threedlib. +const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); +const chart = new Chart({ + container: 'container', + theme: 'classic', + renderer, + depth: 400, // Define the depth of chart. +}); + +chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('color', 'Cylinders') + .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.setType(CameraType.ORBITING); + + // Add a directional light into scene. + const light = new DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas.appendChild(light); +}); diff --git a/site/examples/3d/scatter/demo/perspective-projection.ts b/site/examples/3d/scatter/demo/perspective-projection.ts new file mode 100644 index 0000000000..b858addcaa --- /dev/null +++ b/site/examples/3d/scatter/demo/perspective-projection.ts @@ -0,0 +1,58 @@ +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, corelib, threedlib, extend } from '@antv/g2'; + +// Create a WebGL renderer. +const renderer = new WebGLRenderer(); +renderer.registerPlugin(new ThreeDPlugin()); +renderer.registerPlugin(new ControlPlugin()); + +// Customize our own Chart with threedlib. +const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); +const chart = new Chart({ + container: 'container', + theme: 'classic', + renderer, + depth: 400, // Define the depth of chart. +}); + +chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('color', 'Cylinders') + .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(); + // Use perspective projection mode. + camera.setPerspective(0.1, 5000, 45, 640 / 480); + camera.setType(CameraType.ORBITING); + + // Add a directional light into scene. + const light = new DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas.appendChild(light); +}); diff --git a/site/examples/3d/scatter/demo/sphere-shape.ts b/site/examples/3d/scatter/demo/sphere-shape.ts new file mode 100644 index 0000000000..f7d68853ef --- /dev/null +++ b/site/examples/3d/scatter/demo/sphere-shape.ts @@ -0,0 +1,58 @@ +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, corelib, threedlib, extend } from '@antv/g2'; + +// Create a WebGL renderer. +const renderer = new WebGLRenderer(); +renderer.registerPlugin(new ThreeDPlugin()); +renderer.registerPlugin(new ControlPlugin()); + +// Customize our own Chart with threedlib. +const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); +const chart = new Chart({ + container: 'container', + theme: 'classic', + renderer, + depth: 400, // Define the depth of chart. +}); + +chart + .point3D() + .data({ + type: 'fetch', + value: + 'https://gw.alipayobjects.com/os/bmw-prod/2c813e2d-2276-40b9-a9af-cf0a0fb7e942.csv', + }) + .encode('x', 'Horsepower') + .encode('y', 'Miles_per_Gallon') + .encode('z', 'Weight_in_lbs') + .encode('size', 'Origin') + .encode('color', 'Cylinders') + .encode('shape', 'sphere') + .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, 45, 640 / 480); + camera.setType(CameraType.ORBITING); + + // Add a directional light into scene. + const light = new DirectionalLight({ + style: { + intensity: 3, + fill: 'white', + direction: [-1, 0, 1], + }, + }); + canvas.appendChild(light); +}); diff --git a/site/examples/3d/scatter/index.en.md b/site/examples/3d/scatter/index.en.md new file mode 100644 index 0000000000..f0033100eb --- /dev/null +++ b/site/examples/3d/scatter/index.en.md @@ -0,0 +1,4 @@ +--- +title: 3D Scatter Chart +order: 1 +--- diff --git a/site/examples/3d/scatter/index.zh.md b/site/examples/3d/scatter/index.zh.md new file mode 100644 index 0000000000..3bb0cf62d9 --- /dev/null +++ b/site/examples/3d/scatter/index.zh.md @@ -0,0 +1,4 @@ +--- +title: 3D 散点图 +order: 1 +--- diff --git a/site/package.json b/site/package.json index 62c5477054..04e2804f85 100644 --- a/site/package.json +++ b/site/package.json @@ -13,6 +13,8 @@ "@antv/g-lottie-player": "^0.2.7", "@antv/g-pattern": "^1.2.7", "@antv/g-plugin-a11y": "^0.6.7", + "@antv/g-plugin-3d": "^1.9.5", + "@antv/g-plugin-control": "^1.9.5", "@antv/g-plugin-rough-canvas-renderer": "^1.9.7", "@antv/g-svg": "^1.10.7", "@antv/g-webgl": "^1.9.8", diff --git a/src/api/runtime.ts b/src/api/runtime.ts index 3ecd3f4eb7..1f6e4de39c 100644 --- a/src/api/runtime.ts +++ b/src/api/runtime.ts @@ -301,11 +301,11 @@ export class Runtime extends CompositionNode { private _computedOptions() { const options = this.options(); const { key = G2_CHART_KEY } = options; - const { width, height } = sizeOf(options, this._container); + const { width, height, depth } = sizeOf(options, this._container); this._width = width; this._height = height; this._key = key; - return { key: this._key, ...options, width, height }; + return { key: this._key, ...options, width, height, depth }; } // Create canvas if it does not exist. diff --git a/src/api/utils.ts b/src/api/utils.ts index b658ef5373..262c02ef39 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -8,6 +8,7 @@ import { Node } from './node'; export const VIEW_KEYS = [ 'width', 'height', + 'depth', 'padding', 'paddingLeft', 'paddingRight', @@ -63,8 +64,8 @@ export function valueOf(node: Node): Record { export function sizeOf(options, container) { const { autoFit } = options; if (autoFit) return getContainerSize(container); - const { width = 640, height = 480 } = options; - return { width, height }; + const { width = 640, height = 480, depth = 0 } = options; + return { width, height, depth }; } export function optionsOf(node: Node): Record { diff --git a/src/component/axis.ts b/src/component/axis.ts index 72f343b6f2..fcb697a6bb 100644 --- a/src/component/axis.ts +++ b/src/component/axis.ts @@ -11,7 +11,9 @@ import { GuideComponentComponent as GCC, GuideComponentOrientation as GCO, GuideComponentPosition as GCP, + GuideComponentPlane, Scale, + Vector3, } from '../runtime'; import { angleOf, @@ -24,11 +26,11 @@ import { radiusOf, } from '../utils/coordinate'; import { capitalizeFirst } from '../utils/helper'; -import { isOrdinalScale } from '../utils/scale'; import { adaptor, isVertical, titleContent } from './utils'; export type AxisOptions = { position?: GCP; + plane?: GuideComponentPlane; zIndex?: number; title?: string | string[]; direction?: 'left' | 'center' | 'right'; @@ -50,13 +52,31 @@ export type AxisOptions = { grid: any; // options won't be overridden important: Record; + /** + * Rotation origin. + */ + origin?: Vector3; + /** + * EulerAngles of rotation. + */ + eulerAngles?: Vector3; [key: string]: any; }; -function sizeOf(coordinate: Coordinate): [number, number] { +export function rotateAxis(axis: DisplayObject, options: AxisOptions) { + const { eulerAngles, origin } = options; + if (origin) { + axis.setOrigin(origin); + } + if (eulerAngles) { + axis.rotate(eulerAngles[0], eulerAngles[1], eulerAngles[2]); + } +} + +function sizeOf(coordinate: Coordinate): [number, number, number] { // @ts-ignore - const { innerWidth, innerHeight } = coordinate.getOptions(); - return [innerWidth, innerHeight]; + const { innerWidth, innerHeight, depth } = coordinate.getOptions(); + return [innerWidth, innerHeight, depth]; } function createFisheye(position, coordinate) { @@ -193,10 +213,23 @@ function getData( }); } -function inferGridLength(position: GCP, coordinate: Coordinate) { - const [width, height] = sizeOf(coordinate); - if (position.includes('bottom') || position.includes('top')) return height; - return width; +function inferGridLength( + position: GCP, + coordinate: Coordinate, + plane: GuideComponentPlane = 'xy', +) { + const [width, height, depth] = sizeOf(coordinate); + + if (plane === 'xy') { + if (position.includes('bottom') || position.includes('top')) return height; + return width; + } else if (plane === 'xz') { + if (position.includes('bottom') || position.includes('top')) return depth; + return width; + } else { + if (position.includes('bottom') || position.includes('top')) return height; + return depth; + } } function inferLabelOverlap(transform = [], style: Record) { @@ -280,6 +313,20 @@ function inferGrid(value: boolean, coordinate: Coordinate, scale: Scale) { return value === undefined ? !!scale.getTicks : value; } +function infer3DAxisLinearOverrideStyle(coordinate: Coordinate) { + // @ts-ignore + const { depth } = coordinate.getOptions(); + return depth + ? { + tickIsBillboard: true, + lineIsBillboard: true, + labelIsBillboard: true, + titleIsBillboard: true, + gridIsBillboard: true, + } + : {}; +} + function inferAxisLinearOverrideStyle( position: GCP, orientation: GCO, @@ -492,6 +539,7 @@ const LinearAxisComponent: GCC = (options) => { labelFormatter, order, orientation, + actualPosition, position, size, style = {}, @@ -523,7 +571,12 @@ const LinearAxisComponent: GCC = (options) => { ...userDefinitions, }; - const gridLength = inferGridLength(position, coordinate); + const gridLength = inferGridLength( + actualPosition || position, + coordinate, + options.plane, + ); + const overrideStyle = inferAxisLinearOverrideStyle( position, orientation, @@ -531,6 +584,8 @@ const LinearAxisComponent: GCC = (options) => { coordinate, ); + const threeDOverrideStyle = infer3DAxisLinearOverrideStyle(coordinate); + const data = getData( scale, domain, @@ -568,6 +623,7 @@ const LinearAxisComponent: GCC = (options) => { indexBBox, ...(!internalAxisStyle.line ? { lineOpacity: 0 } : null), ...overrideStyle, + ...threeDOverrideStyle, ...important, }; diff --git a/src/component/axisX.ts b/src/component/axisX.ts index 9ae981aef2..139eb0b09f 100644 --- a/src/component/axisX.ts +++ b/src/component/axisX.ts @@ -1,5 +1,5 @@ import { GuideComponentComponent as GCC } from '../runtime'; -import { LinearAxis, AxisOptions } from './axis'; +import { AxisOptions, LinearAxis, rotateAxis } from './axis'; export type AxisXOptions = AxisOptions; @@ -7,9 +7,14 @@ export type AxisXOptions = AxisOptions; * LinearAxis component bind to x scale. */ export const AxisX: GCC = (options) => { - return (...args) => + return (...args) => { // empirical value for crossPadding - LinearAxis(Object.assign({}, { crossPadding: 50 }, options))(...args); + const axisX = LinearAxis(Object.assign({}, { crossPadding: 50 }, options))( + ...args, + ); + rotateAxis(axisX, options); + return axisX; + }; }; AxisX.props = { diff --git a/src/component/axisY.ts b/src/component/axisY.ts index eae555c6af..d6b147d9d1 100644 --- a/src/component/axisY.ts +++ b/src/component/axisY.ts @@ -1,5 +1,5 @@ import { GuideComponentComponent as GCC } from '../runtime'; -import { AxisOptions, LinearAxis } from './axis'; +import { AxisOptions, LinearAxis, rotateAxis } from './axis'; export type AxisYOptions = AxisOptions; @@ -7,8 +7,13 @@ export type AxisYOptions = AxisOptions; * LinearAxis component bind to y scale. */ export const AxisY: GCC = (options) => { - return (...args) => - LinearAxis(Object.assign({}, { crossPadding: 10 }, options))(...args); + return (...args) => { + const axisY = LinearAxis(Object.assign({}, { crossPadding: 10 }, options))( + ...args, + ); + rotateAxis(axisY, options); + return axisY; + }; }; AxisY.props = { diff --git a/src/component/axisZ.ts b/src/component/axisZ.ts new file mode 100644 index 0000000000..9faf90fa58 --- /dev/null +++ b/src/component/axisZ.ts @@ -0,0 +1,25 @@ +import { GuideComponentComponent as GCC } from '../runtime'; +import { LinearAxis, AxisOptions, rotateAxis } from './axis'; + +export type AxisXOptions = AxisOptions; + +/** + * LinearAxis component bind to z scale. + */ +export const AxisZ: GCC = (options) => { + return (...args) => { + const axisZ = LinearAxis(Object.assign({}, { crossPadding: 10 }, options))( + ...args, + ); + rotateAxis(axisZ, options); + return axisZ; + }; +}; + +AxisZ.props = { + ...LinearAxis.props, + defaultPosition: 'bottom', + defaultPlane: 'yz', +}; + +export function axisZConfig() {} diff --git a/src/component/index.ts b/src/component/index.ts index 1eaae0e5c9..302aac30bd 100644 --- a/src/component/index.ts +++ b/src/component/index.ts @@ -1,6 +1,7 @@ export { LinearAxis as AxisLinear, ArcAxis as AxisArc } from './axis'; export { AxisX } from './axisX'; export { AxisY } from './axisY'; +export { AxisZ } from './axisZ'; export { AxisRadar } from './axisRadar'; export { LegendCategory } from './legendCategory'; export { LegendContinuous } from './legendContinuous'; diff --git a/src/composition/mark.ts b/src/composition/mark.ts index 064df51451..42b26e08d2 100644 --- a/src/composition/mark.ts +++ b/src/composition/mark.ts @@ -11,6 +11,7 @@ export const Mark: CC = ({ const { width, height, + depth, paddingLeft, paddingRight, paddingTop, @@ -33,6 +34,7 @@ export const Mark: CC = ({ interaction, x, y, + z, key, frame, labelTransform, @@ -48,9 +50,11 @@ export const Mark: CC = ({ type: 'standardView', x, y, + z, key, width, height, + depth, padding, paddingLeft, paddingRight, diff --git a/src/coordinate/cartesian3D.ts b/src/coordinate/cartesian3D.ts new file mode 100644 index 0000000000..8fac654e2f --- /dev/null +++ b/src/coordinate/cartesian3D.ts @@ -0,0 +1,11 @@ +import { Coordinate3DComponent as CC } from '../runtime'; +import { Cartesian3DCoordinate } from '../spec'; + +export type Cartesian3DOptions = Cartesian3DCoordinate; + +/** + * Default coordinate3D transformation for all charts. + */ +export const Cartesian3D: CC = () => [['cartesian3D']]; + +Cartesian3D.props = {}; diff --git a/src/coordinate/index.ts b/src/coordinate/index.ts index 311e64a569..ea08ef4587 100644 --- a/src/coordinate/index.ts +++ b/src/coordinate/index.ts @@ -1,4 +1,5 @@ export { Cartesian } from './cartesian'; +export { Cartesian3D } from './cartesian3D'; export { Polar, getPolarOptions } from './polar'; export { Helix } from './helix'; export { Transpose } from './transpose'; @@ -9,6 +10,7 @@ export { Fisheye } from './fisheye'; export { Radar } from './radar'; export type { CartesianOptions } from './cartesian'; +export type { Cartesian3DOptions } from './cartesian3D'; export type { PolarOptions } from './polar'; export type { HelixOptions } from './helix'; export type { TransposeOptions } from './transpose'; diff --git a/src/index.ts b/src/index.ts index fe46b86196..33eae8c927 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ export { AREA_CLASS_NAME, } from './runtime'; -export { corelib, stdlib, litelib } from './lib'; +export { corelib, stdlib, litelib, threedlib } from './lib'; export * from './mark'; diff --git a/src/lib/index.ts b/src/lib/index.ts index 6356b89857..e254b20b6c 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -2,5 +2,6 @@ export { corelib } from './core'; export { geolib } from './geo'; export { graphlib } from './graph'; export { plotlib } from './plot'; +export { threedlib } from './threed'; export { stdlib } from './std'; export { litelib } from './lite'; diff --git a/src/lib/threed.ts b/src/lib/threed.ts new file mode 100644 index 0000000000..af29661450 --- /dev/null +++ b/src/lib/threed.ts @@ -0,0 +1,11 @@ +import { Cartesian3D } from '../coordinate'; +import { AxisZ } from '../component'; +import { Point3D } from '../mark'; + +export function threedlib() { + return { + 'coordinate.cartesian3D': Cartesian3D, + 'component.axisZ': AxisZ, + 'mark.point3D': Point3D, + } as const; +} diff --git a/src/mark/index.ts b/src/mark/index.ts index ae5a12949b..ee029a3e60 100644 --- a/src/mark/index.ts +++ b/src/mark/index.ts @@ -2,6 +2,7 @@ export { Interval } from './interval'; export { Rect } from './rect'; export { Line } from './line'; export { Point } from './point'; +export { Point3D } from './point3D'; export { Text } from './text'; export { Cell } from './cell'; export { Area } from './area'; diff --git a/src/mark/point3D.ts b/src/mark/point3D.ts new file mode 100644 index 0000000000..878aaf2ccf --- /dev/null +++ b/src/mark/point3D.ts @@ -0,0 +1,90 @@ +import { Coordinate3D } from '@antv/coord'; +import { MarkComponent as MC, Vector3 } from '../runtime'; +import { PointMark } from '../spec'; +import { MaybeZeroX, MaybeZeroY, MaybeZeroZ, MaybeSize } from '../transform'; +import { Sphere, Cube } from '../shape'; +import { + baseGeometryChannels, + basePostInference, + basePreInference, + tooltip3d, +} from './utils'; + +export type PointOptions = Omit; + +/** + * Convert value for each channel to point shapes. + * Calc the bbox of each point based on x, y and r. + * This is for allowing their radius can be affected by coordinate(e.g. fisheye). + */ +export const Point3D: MC = (options) => { + return (index, _, value, coordinate) => { + const { x: X, y: Y, z: Z, size: S, dx: DX, dy: DY, dz: DZ } = value; + const [width, height, depth] = ( + coordinate as unknown as Coordinate3D + ).getSize(); + const xyz: (i: number) => Vector3 = (i) => { + const dx = +(DX?.[i] || 0); + const dy = +(DY?.[i] || 0); + const dz = +(DZ?.[i] || 0); + const x = +X[i]; + const y = +Y[i]; + const z = +Z[i]; + const cx = x + dx; + const cy = y + dy; + const cz = z + dz; + return [cx, cy, cz]; + }; + const P = S + ? Array.from(index, (i) => { + const [cx, cy, cz] = xyz(i); + const r = +S[i]; + const a = r / width; + const b = r / height; + const c = r / depth; + const p1: Vector3 = [cx - a, cy - b, cz - c]; + const p2: Vector3 = [cx + a, cy + b, cz + c]; + return [ + (coordinate as unknown as Coordinate3D).map([...p1, cz]), + (coordinate as unknown as Coordinate3D).map([...p2, cz]), + ] as Vector3[]; + }) + : Array.from(index, (i) => { + const [cx, cy, cz] = xyz(i); + return [ + (coordinate as unknown as Coordinate3D).map([cx, cy, cz]), + ] as Vector3[]; + }); + return [index, P]; + }; +}; + +const shape = { + sphere: Sphere, + cube: Cube, +}; + +Point3D.props = { + defaultShape: 'sphere', + defaultLabelShape: 'label', + composite: false, + shape, + channels: [ + ...baseGeometryChannels({ shapes: Object.keys(shape) }), + { name: 'x', required: true }, + { name: 'y', required: true }, + { name: 'z', required: true }, + { name: 'series', scale: 'band' }, + { name: 'size', quantitative: 'sqrt' }, + { name: 'dx', scale: 'identity' }, + { name: 'dy', scale: 'identity' }, + { name: 'dz', scale: 'identity' }, + ], + preInference: [ + ...basePreInference(), + { type: MaybeZeroX }, + { type: MaybeZeroY }, + { type: MaybeZeroZ }, + ], + postInference: [...basePostInference(), { type: MaybeSize }, ...tooltip3d()], +}; diff --git a/src/mark/utils.ts b/src/mark/utils.ts index 6b3f92f6af..9efdb1c238 100644 --- a/src/mark/utils.ts +++ b/src/mark/utils.ts @@ -30,6 +30,13 @@ export function baseGeometryChannels(options: ChannelOptions = {}): Channel[] { return [...baseChannels(options), { name: 'title', scale: 'identity' }]; } +export function tooltip3d() { + return [ + { type: MaybeTitle, channel: 'color' }, + { type: MaybeTooltip, channel: ['x', 'y', 'z'] }, + ]; +} + export function tooltip2d() { return [ { type: MaybeTitle, channel: 'color' }, diff --git a/src/runtime/component.ts b/src/runtime/component.ts index 9437bdc427..23364ef1b9 100644 --- a/src/runtime/component.ts +++ b/src/runtime/component.ts @@ -102,6 +102,7 @@ export function inferComponent( const { props } = createGuideComponent(type); const { defaultPosition, + defaultPlane = 'xy', defaultOrientation, defaultSize, defaultOrder, @@ -148,6 +149,7 @@ export function inferComponent( defaultSize, length, position, + plane: defaultPlane, orientation, padding, order, @@ -363,6 +365,10 @@ function inferAxisComponentType( if (isRadial(coordinates)) return ['axisArc', [scale]]; return [isTranspose(coordinates) ? 'axisX' : 'axisY', [scale]]; } + // Only support linear axis for z. + if (name.startsWith('z')) { + return ['axisZ', [scale]]; + } if (name.startsWith('position')) { if (isRadar(coordinates)) return ['axisRadar', [scale]]; if (!isPolar(coordinates)) return ['axisY', [scale]]; diff --git a/src/runtime/coordinate.ts b/src/runtime/coordinate.ts index e8b2c09ee2..c2664af7a3 100644 --- a/src/runtime/coordinate.ts +++ b/src/runtime/coordinate.ts @@ -1,4 +1,4 @@ -import { Coordinate } from '@antv/coord'; +import { Coordinate, Coordinate3D } from '@antv/coord'; import { G2View, G2CoordinateOptions, G2Library } from './types/options'; import { CoordinateComponent, CoordinateTransform } from './types/component'; import { useLibrary } from './library'; @@ -26,7 +26,9 @@ export function createCoordinate( } = layout; const { coordinates: partialTransform = [] } = partialOptions; const transform = inferCoordinate(partialTransform); - const coordinate = new Coordinate({ + + const isCartesian3D = transform[0].type === 'cartesian3D'; + const options = { // @todo Find a better solution. // Store more layout information for component. ...layout, @@ -35,8 +37,13 @@ export function createCoordinate( width: innerWidth - insetLeft - insetRight, height: innerHeight - insetBottom - insetTop, transformations: transform.map(useCoordinate).flat(), - }); - return coordinate; + }; + + const coordinate = isCartesian3D + ? // @ts-ignore + new Coordinate3D(options) + : new Coordinate(options); + return coordinate as Coordinate; } export function coordinate2Transform(node: G2View, library: G2Library): G2View { @@ -112,6 +119,9 @@ export function isReflectY(coordinates: G2CoordinateOptions[]) { function inferCoordinate( coordinates: G2CoordinateOptions[], ): G2CoordinateOptions[] { - if (coordinates.find((d) => d.type === 'cartesian')) return coordinates; + if ( + coordinates.find((d) => d.type === 'cartesian' || d.type === 'cartesian3D') + ) + return coordinates; return [...coordinates, { type: 'cartesian' }]; } diff --git a/src/runtime/layout.ts b/src/runtime/layout.ts index 8d98b8b04d..5e414c1d5c 100644 --- a/src/runtime/layout.ts +++ b/src/runtime/layout.ts @@ -12,6 +12,7 @@ import { Section, SectionArea, G2Theme, + GuideComponentPlane, } from './types/common'; import { computeComponentSize, @@ -24,6 +25,45 @@ import { import { G2GuideComponentOptions, G2Library, G2View } from './types/options'; import { isPolar as isPolarOptions } from './coordinate'; +export function processAxisZ(components: G2GuideComponentOptions[]) { + const axisZ = components.find(({ type }) => type === 'axisZ'); + if (axisZ) { + const axisX = components.find(({ type }) => type === 'axisX'); + axisX.plane = 'xy'; + const axisY = components.find(({ type }) => type === 'axisY'); + axisY.plane = 'xy'; + axisZ.plane = 'yz'; + axisZ.origin = [axisX.bbox.x, axisX.bbox.y, 0]; + axisZ.eulerAngles = [0, -90, 0]; + axisZ.bbox.x = axisX.bbox.x; + axisZ.bbox.y = axisX.bbox.y; + components.push({ + ...axisX, + plane: 'xz', + showLabel: false, + showTitle: false, + origin: [axisX.bbox.x, axisX.bbox.y, 0], + eulerAngles: [-90, 0, 0], + }); + components.push({ + ...axisY, + plane: 'yz', + showLabel: false, + showTitle: false, + origin: [axisY.bbox.x + axisY.bbox.width, axisY.bbox.y, 0], + eulerAngles: [0, -90, 0], + }); + components.push({ + ...axisZ, + plane: 'xz', + actualPosition: 'left', + showLabel: false, + showTitle: false, + eulerAngles: [90, -90, 0], + }); + } +} + export function computeLayout( components: G2GuideComponentOptions[], options: G2View, @@ -33,8 +73,10 @@ export function computeLayout( const { width, height, + depth, x = 0, y = 0, + z = 0, inset = 0, insetLeft = inset, insetTop = inset, @@ -109,6 +151,7 @@ export function computeLayout( return { width, height, + depth, insetLeft, insetTop, insetBottom, @@ -125,6 +168,7 @@ export function computeLayout( marginRight, x, y, + z, }; } @@ -314,7 +358,11 @@ export function placeComponents( coordinate: Coordinate, layout: Layout, ): void { - const positionComponents = group(components, (d) => d.position); + // Group components by plane & position. + const positionComponents = group( + components, + (d) => `${d.plane || 'xy'}-${d.position}`, + ); const { paddingLeft, paddingRight, @@ -332,8 +380,135 @@ export function placeComponents( insetTop, height, width, + depth, } = layout; + const planes = { + xy: createSection({ + width, + height, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + marginLeft, + marginTop, + marginBottom, + marginRight, + innerHeight, + innerWidth, + insetBottom, + insetLeft, + insetRight, + insetTop, + }), + yz: createSection({ + width: depth, + height: height, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + marginLeft: 0, + marginTop: 0, + marginBottom: 0, + marginRight: 0, + innerWidth: depth, + innerHeight: height, + insetBottom: 0, + insetLeft: 0, + insetRight: 0, + insetTop: 0, + }), + xz: createSection({ + width, + height: depth, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + marginLeft: 0, + marginTop: 0, + marginBottom: 0, + marginRight: 0, + innerWidth: width, + innerHeight: depth, + insetBottom: 0, + insetLeft: 0, + insetRight: 0, + insetTop: 0, + }), + }; + + for (const [key, components] of positionComponents.entries()) { + const [plane, position] = key.split('-') as [GuideComponentPlane, GCP]; + const area = planes[plane][position]; + + /** + * @description non-entity components: axis in the center, inner, outer, component in the center + * @description entity components: other components + * @description no volume components take up no extra space + */ + + const [nonEntityComponents, entityComponents] = divide( + components, + (component) => { + if (typeof component.type !== 'string') return false; + if (position === 'center') return true; + if ( + component.type.startsWith('axis') && + ['inner', 'outer'].includes(position) + ) { + return true; + } + return false; + }, + ); + + if (nonEntityComponents.length) { + placeNonEntityComponents(nonEntityComponents, coordinate, area, position); + } + if (entityComponents.length) { + placePaddingArea(components, coordinate, area); + } + } +} + +function createSection({ + width, + height, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + marginLeft, + marginTop, + marginBottom, + marginRight, + innerHeight, + innerWidth, + insetBottom, + insetLeft, + insetRight, + insetTop, +}: { + width: number; + height: number; + paddingLeft: number; + paddingRight: number; + paddingTop: number; + paddingBottom: number; + marginLeft: number; + marginTop: number; + marginBottom: number; + marginRight: number; + innerHeight: number; + innerWidth: number; + insetBottom: number; + insetLeft: number; + insetRight: number; + insetTop: number; +}): Section { const pl = paddingLeft + marginLeft; const pt = paddingTop + marginTop; const pr = paddingRight + marginRight; @@ -349,7 +524,7 @@ export function placeComponents( null, ]; - const section: Section = { + const xySection: Section = { top: [pl, 0, innerWidth, pt, 'vertical', true, ascending], right: [width - pr, pt, pr, innerHeight, 'horizontal', false, ascending], bottom: [pl, height - pb, innerWidth, pb, 'vertical', false, ascending], @@ -379,37 +554,7 @@ export function placeComponents( outer: centerSection, }; - for (const [position, components] of positionComponents.entries()) { - const area = section[position]; - - /** - * @description non-entity components: axis in the center, inner, outer, component in the center - * @description entity components: other components - * @description no volume components take up no extra space - */ - - const [nonEntityComponents, entityComponents] = divide( - components, - (component) => { - if (typeof component.type !== 'string') return false; - if (position === 'center') return true; - if ( - component.type.startsWith('axis') && - ['inner', 'outer'].includes(position) - ) { - return true; - } - return false; - }, - ); - - if (nonEntityComponents.length) { - placeNonEntityComponents(nonEntityComponents, coordinate, area, position); - } - if (entityComponents.length) { - placePaddingArea(components, coordinate, area); - } - } + return xySection; } function placeNonEntityComponents( diff --git a/src/runtime/plot.ts b/src/runtime/plot.ts index 9e405b19cc..23d5452922 100644 --- a/src/runtime/plot.ts +++ b/src/runtime/plot.ts @@ -33,7 +33,12 @@ import { VIEW_CLASS_NAME, } from './constant'; import { coordinate2Transform, createCoordinate } from './coordinate'; -import { computeLayout, computeRoughPlotSize, placeComponents } from './layout'; +import { + computeLayout, + computeRoughPlotSize, + placeComponents, + processAxisZ, +} from './layout'; import { documentOf, useLibrary } from './library'; import { initializeMark } from './mark'; import { @@ -626,6 +631,9 @@ function initializeState( // Place components and mutate their bbox. placeComponents(groupComponents(components), coordinate, layout); + // AxisZ need a copy of axisX and axisY to show grids in X-Z & Y-Z planes. + processAxisZ(components); + // Scale from marks and components. const scaleInstance = {}; diff --git a/src/runtime/render.ts b/src/runtime/render.ts index 8f03b53c05..a55e1a2df3 100644 --- a/src/runtime/render.ts +++ b/src/runtime/render.ts @@ -70,7 +70,7 @@ export function render( }, ): HTMLElement { // Initialize the context if it is not provided. - const { width = 640, height = 480, theme } = options; + const { width = 640, height = 480, depth = 0, theme } = options; if (!theme) { error( 'ChartOptions.theme is required, such as `const chart = new Chart({ theme: "classic"})`.', @@ -93,9 +93,14 @@ export function render( const selection = select(canvas.document.documentElement); canvas.ready .then(() => - plot({ ...keyed, width, height }, selection, library, context), + plot({ ...keyed, width, height, depth }, selection, library, context), ) .then(() => { + // Place the center of whole scene at z axis' origin. + if (depth) { + canvas!.document.documentElement.translate(0, 0, -depth / 2); + } + // Wait for the next tick. canvas.requestAnimationFrame(() => { emitter.emit(ChartEvent.AFTER_RENDER); diff --git a/src/runtime/types/common.ts b/src/runtime/types/common.ts index e78097f2be..b8db939326 100644 --- a/src/runtime/types/common.ts +++ b/src/runtime/types/common.ts @@ -126,6 +126,7 @@ export type Channel = { }; export type Vector2 = [number, number]; +export type Vector3 = [number, number, number]; export type BBox = { x?: number; @@ -152,6 +153,7 @@ export type GuideComponentPosition = | GuideCompositePosition; export type GuideComponentOrientation = 'horizontal' | 'vertical' | number; +export type GuideComponentPlane = 'xy' | 'xz' | 'yz'; export type Layout = { paddingLeft?: number; @@ -173,6 +175,8 @@ export type Layout = { marginRight?: number; x?: number; y?: number; + z?: number; + depth?: number; }; export type Direction = 'horizontal' | 'vertical' | 'center'; diff --git a/src/runtime/types/component.ts b/src/runtime/types/component.ts index 6e7a7b7393..f4bbf52909 100644 --- a/src/runtime/types/component.ts +++ b/src/runtime/types/component.ts @@ -1,4 +1,4 @@ -import { Coordinate, Transformation } from '@antv/coord'; +import { Coordinate, Transformation, Transformation3D } from '@antv/coord'; import EventEmitter from '@antv/event-emitter'; import { DisplayObject, IAnimation as GAnimation, IDocument } from '@antv/g'; import { @@ -9,6 +9,7 @@ import { IndexedValue, Vector2, G2MarkState, + GuideComponentPlane, } from './common'; import { DataComponent } from './data'; import { Encode, EncodeComponent } from './encode'; @@ -115,6 +116,13 @@ export type CoordinateComponent> = G2BaseComponent< CoordinateProps >; +export type Coordinate3DTransform = Transformation3D[]; +export type Coordinate3DProps = { + transform?: boolean; +}; +export type Coordinate3DComponent> = + G2BaseComponent; + export type Palette = string[]; export type PaletteComponent> = G2BaseComponent< Palette, @@ -169,6 +177,7 @@ export type GuideComponent = (context: GuideComponentContext) => DisplayObject; export type GuideComponentProps = { defaultPosition?: GuideComponentPosition; + defaultPlane?: GuideComponentPlane; defaultOrientation?: GuideComponentOrientation; defaultSize?: number; defaultOrder?: number; diff --git a/src/runtime/types/options.ts b/src/runtime/types/options.ts index 617a655e22..c932b30cd1 100644 --- a/src/runtime/types/options.ts +++ b/src/runtime/types/options.ts @@ -4,6 +4,7 @@ import { Canvas, IAnimation as GAnimation } from '@antv/g'; import { G2Title, G2ViewDescriptor, + GuideComponentPlane, GuideComponentPosition, Layout, Primitive, @@ -32,6 +33,7 @@ import { TransformComponent } from './transform'; export type G2ViewTree = { width?: number; height?: number; + depth?: number; } & Node; export type Node = { @@ -68,8 +70,10 @@ export type G2View = { key?: string; x?: number; y?: number; + z?: number; width?: number; height?: number; + depth?: number; padding?: number; paddingLeft?: number; paddingRight?: number; @@ -186,6 +190,7 @@ export type G2GuideComponentOptions = G2BaseComponentOptions< { scale?: G2ScaleOptions; position?: GuideComponentPosition; + plane?: GuideComponentPlane; size?: number; order?: number; zIndex?: number; diff --git a/src/shape/index.ts b/src/shape/index.ts index 6a66814ccf..986e365d7f 100644 --- a/src/shape/index.ts +++ b/src/shape/index.ts @@ -29,6 +29,8 @@ export { Square as PointSquare } from './point/square'; export { Tick as PointTick } from './point/tick'; 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 { Vector as VectorShape } from './vector/vector'; export { Text as TextShape } from './text/text'; export { Badge as TextBadge } from './text/badge'; diff --git a/src/shape/point3D/cube.ts b/src/shape/point3D/cube.ts new file mode 100644 index 0000000000..ebd4ee2fb6 --- /dev/null +++ b/src/shape/point3D/cube.ts @@ -0,0 +1,68 @@ +import { MeshPhongMaterial, CubeGeometry, Mesh } from '@antv/g-plugin-3d'; +import { applyStyle, getOrigin, toOpacityKey } from '../utils'; +import { ShapeComponent as SC } from '../../runtime'; +import { select } from '../../utils/selection'; + +const GEOMETRY_SIZE = 5; + +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 sphere geometry + // @ts-ignore + context.cubeGeometry = new CubeGeometry(device, { + width: GEOMETRY_SIZE * 2, + height: GEOMETRY_SIZE * 2, + depth: GEOMETRY_SIZE * 2, + }); + // create a material with Phong lighting model + // @ts-ignore + context.cubeMaterial = new MeshPhongMaterial(device); + } + + return (points, value, defaults) => { + const { color: defaultColor } = defaults; + const { color = defaultColor, transform, opacity } = value; + const [cx, cy, cz] = getOrigin(points); + const r = value.size; + const finalRadius = r || style.r || defaults.r; + + 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); + const scaling = finalRadius / GEOMETRY_SIZE; + cube.scale(scaling); + + return select(cube) + .call(applyStyle, defaults) + .style('fill', color) + .style('transform', transform) + .style(toOpacityKey(options), opacity) + .call(applyStyle, style) + .node(); + }; +}; + +Cube.props = { + defaultMarker: 'cube', +}; diff --git a/src/shape/point3D/sphere.ts b/src/shape/point3D/sphere.ts new file mode 100644 index 0000000000..07a061b31c --- /dev/null +++ b/src/shape/point3D/sphere.ts @@ -0,0 +1,68 @@ +import { MeshPhongMaterial, SphereGeometry, Mesh } from '@antv/g-plugin-3d'; +import { applyStyle, getOrigin, toOpacityKey } from '../utils'; +import { ShapeComponent as SC } from '../../runtime'; +import { select } from '../../utils/selection'; + +export type SphereOptions = Record; + +const GEOMETRY_SIZE = 5; + +/** + * @see https://g.antv.antgroup.com/api/3d/geometry#spheregeometry + */ +export const Sphere: SC = (options, context) => { + // Render border only when colorAttribute is stroke. + const { ...style } = options; + + // @ts-ignore + if (!context.sphereGeometry) { + const renderer = context.canvas.getConfig().renderer; + const plugin = renderer.getPlugin('device-renderer'); + const device = plugin.getDevice(); + // create a sphere geometry + // @ts-ignore + context.sphereGeometry = new SphereGeometry(device, { + radius: GEOMETRY_SIZE, + latitudeBands: 32, + longitudeBands: 32, + }); + // create a material with Phong lighting model + // @ts-ignore + context.sphereMaterial = new MeshPhongMaterial(device); + } + + return (points, value, defaults) => { + const { color: defaultColor } = defaults; + const { color = defaultColor, transform, opacity } = value; + const [cx, cy, cz] = getOrigin(points); + const r = value.size; + const finalRadius = r || style.r || defaults.r; + + const sphere = new Mesh({ + style: { + x: cx, + y: cy, + z: cz, + // @ts-ignore + geometry: context.sphereGeometry, + // @ts-ignore + material: context.sphereMaterial, + }, + }); + sphere.setOrigin(0, 0, 0); + const scaling = finalRadius / GEOMETRY_SIZE; + sphere.scale(scaling); + + return select(sphere) + .call(applyStyle, defaults) + .style('fill', color) + .style('transform', transform) + .style(toOpacityKey(options), opacity) + .call(applyStyle, style) + .node(); + }; +}; + +Sphere.props = { + defaultMarker: 'sphere', +}; diff --git a/src/shape/utils.ts b/src/shape/utils.ts index 797dcf9cf1..799daaa9e6 100644 --- a/src/shape/utils.ts +++ b/src/shape/utils.ts @@ -3,7 +3,7 @@ import { Linear } from '@antv/scale'; import { lowerFirst } from '@antv/util'; import { extent } from 'd3-array'; import { Path as D3Path } from 'd3-path'; -import { G2Theme, Primitive, Vector2 } from '../runtime'; +import { G2Theme, Primitive, Vector2, Vector3 } from '../runtime'; import { indexOf } from '../utils/array'; import { isPolar, isTranspose } from '../utils/coordinate'; import { Selection } from '../utils/selection'; @@ -212,8 +212,8 @@ export function getTransform(coordinate, value) { return `translate(${center[0]}, ${center[1]}) ${suffix || ''}`; } -export function getOrigin(points: Vector2[]) { +export function getOrigin(points: (Vector2 | Vector3)[]) { if (points.length === 1) return points[0]; - const [[x0, y0], [x2, y2]] = points; - return [(x0 + x2) / 2, (y0 + y2) / 2]; + const [[x0, y0, z0 = 0], [x2, y2, z2 = 0]] = points; + return [(x0 + x2) / 2, (y0 + y2) / 2, (z0 + z2) / 2]; } diff --git a/src/spec/component.ts b/src/spec/component.ts index 24ccf2b065..a885ac7b79 100644 --- a/src/spec/component.ts +++ b/src/spec/component.ts @@ -33,7 +33,7 @@ export type TitleComponent = { } & UsePrefix<'title' | 'subtitle', Record>; export type AxisComponent = { - type?: 'axisX' | 'axisY'; + type?: 'axisX' | 'axisY' | 'axisZ'; tickCount?: number; labelFormatter?: any; tickFilter?: any; diff --git a/src/spec/composition.ts b/src/spec/composition.ts index 65ec111a93..5b90a792bd 100644 --- a/src/spec/composition.ts +++ b/src/spec/composition.ts @@ -42,8 +42,10 @@ export type ViewComposition = { type?: 'view'; x?: number; y?: number; + z?: number; width?: number; height?: number; + depth?: number; data?: Data; key?: string; class?: string; diff --git a/src/spec/coordinate.ts b/src/spec/coordinate.ts index af96beded2..3928df5f94 100644 --- a/src/spec/coordinate.ts +++ b/src/spec/coordinate.ts @@ -7,6 +7,7 @@ export type Coordinate = | ThetaCoordinate | CustomCoordinate | CartesianCoordinate + | Cartesian3DCoordinate | ParallelCoordinate | RadialCoordinate | RadarCoordinate @@ -18,6 +19,7 @@ export type CoordinateTypes = | 'transpose' | 'theta' | 'cartesian' + | 'cartesian3D' | 'parallel' | 'fisheye' | 'radial' @@ -70,6 +72,10 @@ export type CartesianCoordinate = BaseCoordinate<{ type?: 'cartesian'; }>; +export type Cartesian3DCoordinate = BaseCoordinate<{ + type?: 'cartesian3D'; +}>; + export type ParallelCoordinate = BaseCoordinate<{ type?: 'parallel'; }>; diff --git a/src/spec/index.ts b/src/spec/index.ts index 4c2a32da86..1e7895d164 100644 --- a/src/spec/index.ts +++ b/src/spec/index.ts @@ -5,6 +5,7 @@ import { Mark } from './mark'; export type G2Spec = (Mark | Composition | AxisComponent | LegendComponent) & { width?: number; height?: number; + depth?: number; autoFit?: boolean; }; diff --git a/src/spec/mark.ts b/src/spec/mark.ts index 3679f297a7..170cb41bb6 100644 --- a/src/spec/mark.ts +++ b/src/spec/mark.ts @@ -89,6 +89,7 @@ export type MarkTypes = export type ChannelTypes = | 'x' | 'y' + | 'z' | 'x1' | 'y1' | 'series' @@ -114,7 +115,12 @@ export type ChannelTypes = | 'exitDelay' | `position${number}`; -export type PositionChannelTypes = 'x' | 'y' | 'position' | `position${number}`; +export type PositionChannelTypes = + | 'x' + | 'y' + | 'z' + | 'position' + | `position${number}`; export type AtheisticChanelTypes = 'size' | 'color' | 'shape' | 'opacity'; diff --git a/src/transform/index.ts b/src/transform/index.ts index bb88001baf..f98359bd90 100644 --- a/src/transform/index.ts +++ b/src/transform/index.ts @@ -3,6 +3,7 @@ export { MaybeStackY } from './maybeStackY'; export { MaybeTitle } from './maybeTitle'; export { MaybeZeroX } from './maybeZeroX'; export { MaybeZeroY } from './maybeZeroY'; +export { MaybeZeroZ } from './maybeZeroZ'; export { MaybeSize } from './maybeSize'; export { MaybeKey } from './maybeKey'; export { MaybeSeries } from './maybeSeries'; diff --git a/src/transform/maybeZeroZ.ts b/src/transform/maybeZeroZ.ts new file mode 100644 index 0000000000..aeeae03c48 --- /dev/null +++ b/src/transform/maybeZeroZ.ts @@ -0,0 +1,25 @@ +import { deepMix } from '@antv/util'; +import { TransformComponent as TC } from '../runtime'; +import { inferredColumn, constant } from './utils/helper'; + +export type MaybeZeroZOptions = Record; + +/** + * Add zero constant encode for z channel. + */ +export const MaybeZeroZ: TC = () => { + return (I, mark) => { + const { encode } = mark; + const { z } = encode; + if (z !== undefined) return [I, mark]; + return [ + I, + deepMix({}, mark, { + encode: { z: inferredColumn(constant(I, 0)) }, + scale: { z: { guide: null } }, + }), + ]; + }; +}; + +MaybeZeroZ.props = {}; diff --git a/src/utils/size.ts b/src/utils/size.ts index db4fbb92e5..10592ea8ba 100644 --- a/src/utils/size.ts +++ b/src/utils/size.ts @@ -3,6 +3,7 @@ import { G2View } from '../runtime'; type Size = { width: number; height: number; + depth?: number; }; const parseInt10 = (d: string) => (d ? parseInt(d) : 0); @@ -28,6 +29,7 @@ export function getContainerSize(container: HTMLElement): Size { return { width: wrapperWidth - widthPadding, height: wrapperHeight - heightPadding, + depth: wrapperWidth, }; }