diff --git a/__tests__/plots/api/chart-render-3d-scatter-plot-legend.ts b/__tests__/plots/api/chart-render-3d-scatter-plot-legend.ts
new file mode 100644
index 0000000000..5e655f9047
--- /dev/null
+++ b/__tests__/plots/api/chart-render-3d-scatter-plot-legend.ts
@@ -0,0 +1,126 @@
+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';
+
+// 添加图例
+function legendColor(chart) {
+ // 创建 Legend 并且挂在图例
+ const node = chart.getContainer();
+ const legend = document.createElement('div');
+ legend.style.display = 'flex';
+ node.insertBefore(legend, node.childNodes[0]);
+
+ // 创建并挂载 Items
+ const { color: scale } = chart.getScale();
+ const { domain } = scale.getOptions();
+ const items = domain.map((value) => {
+ const item = document.createElement('div');
+ const color = scale.map(value);
+ item.style.marginLeft = '1em';
+ item.innerHTML = `
+
+ ${value}
+ `;
+ return item;
+ });
+ items.forEach((d) => legend.append(d));
+
+ // 监听事件
+ const selectedValues = [...domain];
+ const options = chart.options();
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ const value = domain[i];
+ item.style.cursor = 'pointer';
+ item.onclick = () => {
+ const index = selectedValues.indexOf(value);
+ if (index !== -1) {
+ selectedValues.splice(index, 1);
+ item.style.opacity = 0.5;
+ } else {
+ selectedValues.push(value);
+ item.style.opacity = 1;
+ }
+ changeColor(selectedValues);
+ };
+ }
+
+ // 重新渲染视图
+ function changeColor(value) {
+ const { transform = [] } = options;
+ const newTransform = [{ type: 'filter', color: { value } }, ...transform];
+ chart.options({
+ ...options,
+ transform: newTransform, // 指定新的 transform
+ scale: { color: { domain } },
+ });
+ chart.render(); // 重新渲染图表
+ }
+}
+
+export function chartRender3dScatterPlotLegend(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('color', 'Origin')
+ .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(() => {
+ legendColor(chart);
+
+ 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 };
+}
+
+chartRender3dScatterPlotLegend.skip = true;
diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts
index 3ff7aad970..e8d7271902 100644
--- a/__tests__/plots/api/index.ts
+++ b/__tests__/plots/api/index.ts
@@ -43,3 +43,4 @@ 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';
+export { chartRender3dScatterPlotLegend } from './chart-render-3d-scatter-plot-legend';
diff --git a/site/docs/manual/extra-topics/3d-charts.zh.md b/site/docs/manual/extra-topics/3d-charts.zh.md
index 92863f0391..a212247ba5 100644
--- a/site/docs/manual/extra-topics/3d-charts.zh.md
+++ b/site/docs/manual/extra-topics/3d-charts.zh.md
@@ -11,6 +11,7 @@ order: 11
- 在场景中设置相机
- 添加光源
- 使用相机交互
+- 添加图例
我们暂不支持图例。
@@ -74,9 +75,7 @@ chart
.encode('x', 'Horsepower')
.encode('y', 'Miles_per_Gallon')
.encode('z', 'Weight_in_lbs')
- .encode('size', 'Origin')
- .encode('color', 'Cylinders')
- .encode('shape', 'cube')
+ .encode('color', 'Origin')
.coordinate({ type: 'cartesian3D' })
.scale('x', { nice: true })
.scale('y', { nice: true })
@@ -130,8 +129,7 @@ chart.render().then(() => {
.encode('x', 'Horsepower')
.encode('y', 'Miles_per_Gallon')
.encode('z', 'Weight_in_lbs')
- .encode('color', 'Cylinders')
- .encode('shape', 'cube')
+ .encode('color', 'Origin')
.coordinate({ type: 'cartesian3D' })
.scale('x', { nice: true })
.scale('y', { nice: true })
@@ -195,8 +193,7 @@ camera.rotate(-20, -20, 0);
.encode('x', 'Horsepower')
.encode('y', 'Miles_per_Gallon')
.encode('z', 'Weight_in_lbs')
- .encode('color', 'Cylinders')
- .encode('shape', 'cube')
+ .encode('color', 'Origin')
.coordinate({ type: 'cartesian3D' })
.scale('x', { nice: true })
.scale('y', { nice: true })
@@ -273,8 +270,7 @@ canvas.appendChild(light);
.encode('x', 'Horsepower')
.encode('y', 'Miles_per_Gallon')
.encode('z', 'Weight_in_lbs')
- .encode('color', 'Cylinders')
- .encode('shape', 'cube')
+ .encode('color', 'Origin')
.coordinate({ type: 'cartesian3D' })
.scale('x', { nice: true })
.scale('y', { nice: true })
@@ -310,3 +306,134 @@ canvas.appendChild(light);
3D 场景下的交互和 2D 场景有很大的不同,[g-plugin-control](https://g.antv.antgroup.com/plugins/control) 提供了 3D 场景下基于相机的交互。当我们拖拽画布时,会控制相机绕视点进行旋转操作,而鼠标滚轮的缩放会让相机进行 dolly 操作。
需要注意的是缩放操作在正交投影下是没有效果的,但旋转操作依然有效。
+
+## 添加自定义图例
+
+你可能注意到在上面的例子中我们刻意关闭了图例:
+
+```ts
+chart.legend(false);
+```
+
+这是由于 3D 场景中的图形都会受到相机影响,但像图例这样的 HUD 组件更适合独立绘制。参考[自定义图例](/manual/extra-topics/customization#自定义图例legend),我们可以使用 HTML 自定义图例:
+
+```js | ob { pin: false }
+(() => {
+ // 添加图例
+ function legendColor(chart) {
+ // 创建 Legend 并且挂在图例
+ const node = chart.getContainer();
+ const legend = document.createElement('div');
+ legend.style.display = 'flex';
+ node.insertBefore(legend, node.childNodes[0]);
+
+ // 创建并挂载 Items
+ const { color: scale } = chart.getScale();
+ const { domain } = scale.getOptions();
+ const items = domain.map((value) => {
+ const item = document.createElement('div');
+ const color = scale.map(value);
+ item.style.marginLeft = '1em';
+ item.innerHTML = `
+
+ ${value}
+ `;
+ return item;
+ });
+ items.forEach((d) => legend.append(d));
+
+ // 监听事件
+ const selectedValues = [...domain];
+ const options = chart.options();
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ const value = domain[i];
+ item.style.cursor = 'pointer';
+ item.onclick = () => {
+ const index = selectedValues.indexOf(value);
+ if (index !== -1) {
+ selectedValues.splice(index, 1);
+ item.style.opacity = 0.5;
+ } else {
+ selectedValues.push(value);
+ item.style.opacity = 1;
+ }
+ changeColor(selectedValues);
+ };
+ }
+
+ // 重新渲染视图
+ function changeColor(value) {
+ const { transform = [] } = options;
+ const newTransform = [{ type: 'filter', color: { value } }, ...transform];
+ chart.options({
+ ...options,
+ transform: newTransform, // 指定新的 transform
+ scale: { color: { domain } },
+ });
+ chart.render(); // 重新渲染图表
+ }
+ }
+
+ 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', 'Origin')
+ .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(() => {
+ legendColor(chart);
+
+ 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();
+})();
+```
diff --git a/site/examples/threed/scatter/demo/custom-legend.ts b/site/examples/threed/scatter/demo/custom-legend.ts
new file mode 100644
index 0000000000..494b0ccfec
--- /dev/null
+++ b/site/examples/threed/scatter/demo/custom-legend.ts
@@ -0,0 +1,120 @@
+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';
+
+// 添加图例
+function legendColor(chart) {
+ // 创建 Legend 并且挂在图例
+ const node = chart.getContainer();
+ const legend = document.createElement('div');
+ legend.style.display = 'flex';
+ node.insertBefore(legend, node.childNodes[0]);
+
+ // 创建并挂载 Items
+ const { color: scale } = chart.getScale();
+ const { domain } = scale.getOptions();
+ const items = domain.map((value) => {
+ const item = document.createElement('div');
+ const color = scale.map(value);
+ item.style.marginLeft = '1em';
+ item.innerHTML = `
+
+ ${value}
+ `;
+ return item;
+ });
+ items.forEach((d) => legend.append(d));
+
+ // 监听事件
+ const selectedValues = [...domain];
+ const options = chart.options();
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ const value = domain[i];
+ item.style.cursor = 'pointer';
+ item.onclick = () => {
+ const index = selectedValues.indexOf(value);
+ if (index !== -1) {
+ selectedValues.splice(index, 1);
+ item.style.opacity = 0.5;
+ } else {
+ selectedValues.push(value);
+ item.style.opacity = 1;
+ }
+ changeColor(selectedValues);
+ };
+ }
+
+ // 重新渲染视图
+ function changeColor(value) {
+ const { transform = [] } = options;
+ const newTransform = [{ type: 'filter', color: { value } }, ...transform];
+ chart.options({
+ ...options,
+ transform: newTransform, // 指定新的 transform
+ scale: { color: { domain } },
+ });
+ chart.render(); // 重新渲染图表
+ }
+}
+
+// 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', 'Origin')
+ .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(() => {
+ legendColor(chart);
+
+ 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/meta.json b/site/examples/threed/scatter/demo/meta.json
similarity index 77%
rename from site/examples/3d/scatter/demo/meta.json
rename to site/examples/threed/scatter/demo/meta.json
index 42dd2cc544..ef61f8f1a1 100644
--- a/site/examples/3d/scatter/demo/meta.json
+++ b/site/examples/threed/scatter/demo/meta.json
@@ -27,6 +27,14 @@
"en": "Sphere"
},
"screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*KNCUQqzw2JsAAAAAAAAAAAAADmJ7AQ/original"
+ },
+ {
+ "filename": "custom-legend.ts",
+ "title": {
+ "zh": "自定义图例",
+ "en": "Customize legend"
+ },
+ "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*SmF_Ro5UYcwAAAAAAAAAAAAADmJ7AQ/original"
}
]
}
diff --git a/site/examples/3d/scatter/demo/orthographic-projection.ts b/site/examples/threed/scatter/demo/orthographic-projection.ts
similarity index 98%
rename from site/examples/3d/scatter/demo/orthographic-projection.ts
rename to site/examples/threed/scatter/demo/orthographic-projection.ts
index 2583d096e4..96831d1be1 100644
--- a/site/examples/3d/scatter/demo/orthographic-projection.ts
+++ b/site/examples/threed/scatter/demo/orthographic-projection.ts
@@ -28,7 +28,7 @@ chart
.encode('x', 'Horsepower')
.encode('y', 'Miles_per_Gallon')
.encode('z', 'Weight_in_lbs')
- .encode('color', 'Cylinders')
+ .encode('color', 'Origin')
.encode('shape', 'cube')
.coordinate({ type: 'cartesian3D' })
.scale('x', { nice: true })
diff --git a/site/examples/3d/scatter/demo/perspective-projection.ts b/site/examples/threed/scatter/demo/perspective-projection.ts
similarity index 98%
rename from site/examples/3d/scatter/demo/perspective-projection.ts
rename to site/examples/threed/scatter/demo/perspective-projection.ts
index b858addcaa..2027e66c42 100644
--- a/site/examples/3d/scatter/demo/perspective-projection.ts
+++ b/site/examples/threed/scatter/demo/perspective-projection.ts
@@ -28,7 +28,7 @@ chart
.encode('x', 'Horsepower')
.encode('y', 'Miles_per_Gallon')
.encode('z', 'Weight_in_lbs')
- .encode('color', 'Cylinders')
+ .encode('color', 'Origin')
.encode('shape', 'cube')
.coordinate({ type: 'cartesian3D' })
.scale('x', { nice: true })
diff --git a/site/examples/3d/scatter/demo/sphere-shape.ts b/site/examples/threed/scatter/demo/sphere-shape.ts
similarity index 96%
rename from site/examples/3d/scatter/demo/sphere-shape.ts
rename to site/examples/threed/scatter/demo/sphere-shape.ts
index f7d68853ef..ed0360c95d 100644
--- a/site/examples/3d/scatter/demo/sphere-shape.ts
+++ b/site/examples/threed/scatter/demo/sphere-shape.ts
@@ -28,8 +28,8 @@ chart
.encode('x', 'Horsepower')
.encode('y', 'Miles_per_Gallon')
.encode('z', 'Weight_in_lbs')
- .encode('size', 'Origin')
- .encode('color', 'Cylinders')
+ .encode('color', 'Origin')
+ .encode('size', 'Cylinders')
.encode('shape', 'sphere')
.coordinate({ type: 'cartesian3D' })
.scale('x', { nice: true })
diff --git a/site/examples/3d/scatter/index.en.md b/site/examples/threed/scatter/index.en.md
similarity index 100%
rename from site/examples/3d/scatter/index.en.md
rename to site/examples/threed/scatter/index.en.md
diff --git a/site/examples/3d/scatter/index.zh.md b/site/examples/threed/scatter/index.zh.md
similarity index 100%
rename from site/examples/3d/scatter/index.zh.md
rename to site/examples/threed/scatter/index.zh.md
diff --git a/src/mark/point3D.ts b/src/mark/point3D.ts
index 878aaf2ccf..ec4aa78e30 100644
--- a/src/mark/point3D.ts
+++ b/src/mark/point3D.ts
@@ -65,7 +65,7 @@ const shape = {
};
Point3D.props = {
- defaultShape: 'sphere',
+ defaultShape: 'cube',
defaultLabelShape: 'label',
composite: false,
shape,
diff --git a/src/runtime/render.ts b/src/runtime/render.ts
index a55e1a2df3..48bb9295a3 100644
--- a/src/runtime/render.ts
+++ b/src/runtime/render.ts
@@ -84,7 +84,11 @@ export function render(
} = context;
context.canvas = canvas;
context.emitter = emitter;
- canvas.resize(width, height);
+
+ const { width: prevWidth, height: prevHeight } = canvas.getConfig();
+ if (prevWidth !== width || prevHeight !== height) {
+ canvas.resize(width, height);
+ }
emitter.emit(ChartEvent.BEFORE_RENDER);
@@ -98,7 +102,9 @@ export function render(
.then(() => {
// Place the center of whole scene at z axis' origin.
if (depth) {
- canvas!.document.documentElement.translate(0, 0, -depth / 2);
+ const [x, y] = canvas!.document.documentElement.getPosition();
+ // Since `render` method can be called for multiple times, use setPosition instead of translate here.
+ canvas!.document.documentElement.setPosition(x, y, -depth / 2);
}
// Wait for the next tick.