diff --git a/__tests__/plots/api/chart-render-3d-line-plot-perspective.ts b/__tests__/plots/api/chart-render-3d-line-plot-perspective.ts new file mode 100644 index 0000000000..b40564adfc --- /dev/null +++ b/__tests__/plots/api/chart-render-3d-line-plot-perspective.ts @@ -0,0 +1,66 @@ +import { CameraType } from '@antv/g'; +import { Renderer as WebGLRenderer } from '@antv/g-webgl'; +import { Plugin as ThreeDPlugin } 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 chartRender3dLinePlotPerspective(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, + }); + + /** + * 3D Spiral + * @see https://plotly.com/javascript/3d-line-plots/ + */ + const pointCount = 500; + let r: number; + const data: { x: number; y: number; z: number }[] = []; + + for (let i = 0; i < pointCount; i++) { + r = i * (pointCount - i); + data.push({ + x: r * Math.cos(i / 30), + y: r * Math.sin(i / 30), + z: i, + }); + } + + chart + .line3D() + .data(data) + .encode('x', 'x') + .encode('y', 'y') + .encode('z', 'z') + .encode('size', 4) + .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); + }); + + return { finished }; +} + +chartRender3dLinePlotPerspective.skip = true; diff --git a/__tests__/plots/api/chart-render-3d-line-plot.ts b/__tests__/plots/api/chart-render-3d-line-plot.ts new file mode 100644 index 0000000000..442d27fe44 --- /dev/null +++ b/__tests__/plots/api/chart-render-3d-line-plot.ts @@ -0,0 +1,66 @@ +import { CameraType } from '@antv/g'; +import { Renderer as WebGLRenderer } from '@antv/g-webgl'; +import { Plugin as ThreeDPlugin } 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 chartRender3dLinePlot(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, + }); + + /** + * 3D Line + * @see https://plotly.com/javascript/3d-line-plots/ + */ + const pointCount = 31; + let r: number; + const data: { x: number; y: number; z: number }[] = []; + + for (let i = 0; i < pointCount; i++) { + r = 10 * Math.cos(i / 10); + data.push({ + x: r * Math.cos(i), + y: r * Math.sin(i), + z: i, + }); + } + + chart + .line3D() + .data(data) + .encode('x', 'x') + .encode('y', 'y') + .encode('z', 'z') + .encode('size', 4) + .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); + }); + + return { finished }; +} + +chartRender3dLinePlot.skip = true; diff --git a/__tests__/plots/api/chart-render-3d-scatter-plot-legend.ts b/__tests__/plots/api/chart-render-3d-scatter-plot-legend.ts index f0b6aad456..49ccfbb7b6 100644 --- a/__tests__/plots/api/chart-render-3d-scatter-plot-legend.ts +++ b/__tests__/plots/api/chart-render-3d-scatter-plot-legend.ts @@ -77,7 +77,6 @@ export function chartRender3dScatterPlotLegend(context) { const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); const chart = new Chart({ container, - renderer, depth: 400, }); diff --git a/__tests__/plots/api/chart-render-3d-scatter-plot-perspective.ts b/__tests__/plots/api/chart-render-3d-scatter-plot-perspective.ts index ed6a2cbfce..17f2cc925e 100644 --- a/__tests__/plots/api/chart-render-3d-scatter-plot-perspective.ts +++ b/__tests__/plots/api/chart-render-3d-scatter-plot-perspective.ts @@ -16,7 +16,6 @@ export function chartRender3dScatterPlotPerspective(context) { const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); const chart = new Chart({ container, - renderer, depth: 400, }); diff --git a/__tests__/plots/api/chart-render-3d-scatter-plot.ts b/__tests__/plots/api/chart-render-3d-scatter-plot.ts index 21251492d8..ca590c1bbb 100644 --- a/__tests__/plots/api/chart-render-3d-scatter-plot.ts +++ b/__tests__/plots/api/chart-render-3d-scatter-plot.ts @@ -16,7 +16,6 @@ export function chartRender3dScatterPlot(context) { const Chart = extend(Runtime, { ...corelib(), ...threedlib() }); const chart = new Chart({ container, - renderer, depth: 400, }); diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts index 42f1be5096..b34773393f 100644 --- a/__tests__/plots/api/index.ts +++ b/__tests__/plots/api/index.ts @@ -45,3 +45,5 @@ 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'; +export { chartRender3dLinePlot } from './chart-render-3d-line-plot'; +export { chartRender3dLinePlotPerspective } from './chart-render-3d-line-plot-perspective'; diff --git a/__tests__/unit/lib/threed.spec.ts b/__tests__/unit/lib/threed.spec.ts index f111745a2a..b616c286d9 100644 --- a/__tests__/unit/lib/threed.spec.ts +++ b/__tests__/unit/lib/threed.spec.ts @@ -1,7 +1,7 @@ import { threedlib } from '../../../src/lib'; import { Cartesian3D } from '../../../src/coordinate'; import { AxisZ } from '../../../src/component'; -import { Point3D } from '../../../src/mark'; +import { Point3D, Line3D } from '../../../src/mark'; describe('threedlib', () => { it('threedlib() should returns expected threed components.', () => { @@ -9,6 +9,7 @@ describe('threedlib', () => { 'coordinate.cartesian3D': Cartesian3D, 'component.axisZ': AxisZ, 'mark.point3D': Point3D, + 'mark.line3D': Line3D, }); }); }); diff --git a/site/docs/api/chart.zh.md b/site/docs/api/chart.zh.md index 0603a12dea..3c4fcca131 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.line3D` + +添加 line3D 图形,具体见 [3d](/spec/threed/line-threed)。 + ## 设置属性 ### `chart.width` diff --git a/site/docs/spec/threed/lineThreed.en.md b/site/docs/spec/threed/lineThreed.en.md new file mode 100644 index 0000000000..afdeefc766 --- /dev/null +++ b/site/docs/spec/threed/lineThreed.en.md @@ -0,0 +1,6 @@ +--- +title: line3D +order: 2 +--- + + diff --git a/site/docs/spec/threed/lineThreed.zh.md b/site/docs/spec/threed/lineThreed.zh.md new file mode 100644 index 0000000000..cd5aac57b4 --- /dev/null +++ b/site/docs/spec/threed/lineThreed.zh.md @@ -0,0 +1,74 @@ +--- +title: line3D +order: 2 +--- + +主要用于绘制 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, + }); + + const pointCount = 31; + let r; + const data = []; + + for (let i = 0; i < pointCount; i++) { + r = 10 * Math.cos(i / 10); + data.push({ + x: r * Math.cos(i), + y: r * Math.sin(i), + z: i, + }); + } + + chart + .line3D() + .data(data) + .encode('x', 'x') + .encode('y', 'y') + .encode('z', 'z') + .encode('size', 4) + .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); + }); + + return chart.getContainer(); +})(); +``` + +更多的案例,可以查看[图表示例](/examples)页面。 diff --git a/site/examples/threed/line/demo/meta.json b/site/examples/threed/line/demo/meta.json new file mode 100644 index 0000000000..b3429d1739 --- /dev/null +++ b/site/examples/threed/line/demo/meta.json @@ -0,0 +1,24 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "polyline.ts", + "title": { + "zh": "折线", + "en": "Polyline" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*0MEPQrNRlvoAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "spiral.ts", + "title": { + "zh": "螺旋线", + "en": "Spiral" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*Ak1iTZ1dpI0AAAAAAAAAAAAADmJ7AQ/original" + } + ] +} diff --git a/site/examples/threed/line/demo/polyline.ts b/site/examples/threed/line/demo/polyline.ts new file mode 100644 index 0000000000..b9dcec8c6e --- /dev/null +++ b/site/examples/threed/line/demo/polyline.ts @@ -0,0 +1,60 @@ +import { CameraType } from '@antv/g'; +import { Renderer as WebGLRenderer } from '@antv/g-webgl'; +import { Plugin as ThreeDPlugin } 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. +}); + +/** + * 3D Line + * @see https://plotly.com/javascript/3d-line-plots/ + */ +const pointCount = 31; +let r; +const data = []; + +for (let i = 0; i < pointCount; i++) { + r = 10 * Math.cos(i / 10); + data.push({ + x: r * Math.cos(i), + y: r * Math.sin(i), + z: i, + }); +} + +chart + .line3D() + .data(data) + .encode('x', 'x') + .encode('y', 'y') + .encode('z', 'z') + .encode('size', 4) + .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); +}); diff --git a/site/examples/threed/line/demo/spiral.ts b/site/examples/threed/line/demo/spiral.ts new file mode 100644 index 0000000000..5c4de4b7ed --- /dev/null +++ b/site/examples/threed/line/demo/spiral.ts @@ -0,0 +1,62 @@ +import { CameraType } from '@antv/g'; +import { Renderer as WebGLRenderer } from '@antv/g-webgl'; +import { Plugin as ThreeDPlugin } 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. +}); + +/** + * 3D Spiral + * @see https://plotly.com/javascript/3d-line-plots/ + */ +const pointCount = 500; +let r; +const data = []; + +for (let i = 0; i < pointCount; i++) { + r = i * (pointCount - i); + data.push({ + x: r * Math.cos(i / 30), + y: r * Math.sin(i / 30), + z: i, + }); +} + +chart + .line3D() + .data(data) + .encode('x', 'x') + .encode('y', 'y') + .encode('z', 'z') + .encode('size', 4) + .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.rotate(30, 30, 0); + camera.dolly(30); + camera.setType(CameraType.ORBITING); +}); diff --git a/site/examples/threed/line/index.en.md b/site/examples/threed/line/index.en.md new file mode 100644 index 0000000000..dfbad93091 --- /dev/null +++ b/site/examples/threed/line/index.en.md @@ -0,0 +1,4 @@ +--- +title: 3D Line Chart +order: 2 +--- diff --git a/site/examples/threed/line/index.zh.md b/site/examples/threed/line/index.zh.md new file mode 100644 index 0000000000..c448b7aed1 --- /dev/null +++ b/site/examples/threed/line/index.zh.md @@ -0,0 +1,4 @@ +--- +title: 3D 折线图 +order: 2 +--- diff --git a/src/lib/threed.ts b/src/lib/threed.ts index af29661450..4c55355361 100644 --- a/src/lib/threed.ts +++ b/src/lib/threed.ts @@ -1,11 +1,12 @@ import { Cartesian3D } from '../coordinate'; import { AxisZ } from '../component'; -import { Point3D } from '../mark'; +import { Point3D, Line3D } from '../mark'; export function threedlib() { return { 'coordinate.cartesian3D': Cartesian3D, 'component.axisZ': AxisZ, 'mark.point3D': Point3D, + 'mark.line3D': Line3D, } as const; } diff --git a/src/mark/index.ts b/src/mark/index.ts index ee029a3e60..3d3d9d70bb 100644 --- a/src/mark/index.ts +++ b/src/mark/index.ts @@ -1,6 +1,7 @@ export { Interval } from './interval'; export { Rect } from './rect'; export { Line } from './line'; +export { Line3D } from './line3D'; export { Point } from './point'; export { Point3D } from './point3D'; export { Text } from './text'; diff --git a/src/mark/line3D.ts b/src/mark/line3D.ts new file mode 100644 index 0000000000..b1f7cdb2d6 --- /dev/null +++ b/src/mark/line3D.ts @@ -0,0 +1,81 @@ +import { group } from 'd3-array'; +import { Mark, MarkComponent as MC, SingleMark, Vector3 } from '../runtime'; +import { LineMark } from '../spec'; +import { Line3DShape } from '../shape'; +import { MaybeSeries } from '../transform'; +import { + baseGeometryChannels, + basePostInference, + basePreInference, + tooltip3d, +} from './utils'; + +const shape = { + line: Line3DShape, +}; + +export type LineOptions = Omit; + +const line: Mark = (index, scale, value, coordinate) => { + const { series: S, x: X, y: Y, z: Z } = value; + const { x, y, z } = scale; + + // Because x and y channel is not strictly required in Line.props, + // it should throw error with empty x, y or z channels. + if (X === undefined || Y === undefined || Z === undefined) { + throw new Error('Missing encode for x, y or z channel.'); + } + + // Group data into series. + // There is only one series without specified series encode. + const series = S ? Array.from(group(index, (i) => S[i]).values()) : [index]; + const I = series.map((group) => group[0]).filter((i) => i !== undefined); + + // A group of data corresponds to one line. + const xoffset = (x?.getBandWidth?.() || 0) / 2; + const yoffset = (y?.getBandWidth?.() || 0) / 2; + const zoffset = (z?.getBandWidth?.() || 0) / 2; + const P = Array.from(series, (I) => { + return I.map((i) => + coordinate.map([ + (+X[i] || 0) + xoffset, + (+Y[i] || 0) + yoffset, + (+Z[i] || 0) + zoffset, + ]), + ) as Vector3[]; + }); + return [I, P, series]; +}; + +/** + * Convert value for each channel to line shapes. + */ +export const Line3D: MC = () => { + return (index, scale, value, coordinate) => { + const mark = line; + return (mark as SingleMark)(index, scale, value, coordinate); + }; +}; + +Line3D.props = { + defaultShape: 'line', + defaultLabelShape: 'label', + composite: false, + shape, + channels: [ + ...baseGeometryChannels({ shapes: Object.keys(shape) }), + { name: 'x' }, + { name: 'y' }, + { name: 'z' }, + { name: 'position', independent: true }, + { name: 'size' }, + { name: 'series', scale: 'identity' }, + ], + preInference: [...basePreInference(), { type: MaybeSeries }], + postInference: [...basePostInference(), ...tooltip3d()], + interaction: { + shareTooltip: false, + seriesTooltip: false, + crosshairs: false, + }, +}; diff --git a/src/shape/index.ts b/src/shape/index.ts index 986e365d7f..bc9bd4c555 100644 --- a/src/shape/index.ts +++ b/src/shape/index.ts @@ -10,6 +10,7 @@ export { HV as LineHV } from './line/hv'; export { VH as LineVH } from './line/vh'; export { HVH as LineHVH } from './line/hvh'; export { Trail as LineTrail } from './line/trail'; +export { Line as Line3DShape } from './line3D/line'; export { HollowBowtie as PointHollowBowtie } from './point/hollowBowtie'; export { HollowDiamond as PointHollowDiamond } from './point/hollowDiamond'; export { HollowHexagon as PointHollowHexagon } from './point/hollowHexagon'; diff --git a/src/shape/line3D/line.ts b/src/shape/line3D/line.ts new file mode 100644 index 0000000000..70341e44fe --- /dev/null +++ b/src/shape/line3D/line.ts @@ -0,0 +1,29 @@ +import { Polyline } from '@antv/g'; +import { applyStyle } from '../utils'; +import { ShapeComponent as SC } from '../../runtime'; +import { select } from '../../utils/selection'; + +export type LineOptions = Record; + +export const Line: SC = (options, context) => { + return (P, value, defaults) => { + const { color: defaultColor, lineWidth: defaultSize, ...rest } = defaults; + const { color = defaultColor, size = defaultSize } = value; + const stroke = color; + const finalStyle = { + isBillboard: true, + ...rest, + ...(stroke && { stroke }), + ...(size && { lineWidth: size }), + }; + + return select(new Polyline()) + .style('points', P) + .call(applyStyle, finalStyle) + .node(); + }; +}; + +Line.props = { + defaultMarker: 'line', +}; diff --git a/src/shape/point3D/cube.ts b/src/shape/point3D/cube.ts index ebd4ee2fb6..f1d460408c 100644 --- a/src/shape/point3D/cube.ts +++ b/src/shape/point3D/cube.ts @@ -1,4 +1,4 @@ -import { MeshPhongMaterial, CubeGeometry, Mesh } from '@antv/g-plugin-3d'; +import { MeshLambertMaterial, CubeGeometry, Mesh } from '@antv/g-plugin-3d'; import { applyStyle, getOrigin, toOpacityKey } from '../utils'; import { ShapeComponent as SC } from '../../runtime'; import { select } from '../../utils/selection'; @@ -28,7 +28,7 @@ export const Cube: SC = (options, context) => { }); // create a material with Phong lighting model // @ts-ignore - context.cubeMaterial = new MeshPhongMaterial(device); + context.cubeMaterial = new MeshLambertMaterial(device); } return (points, value, defaults) => { diff --git a/src/shape/point3D/sphere.ts b/src/shape/point3D/sphere.ts index 07a061b31c..1e27367b56 100644 --- a/src/shape/point3D/sphere.ts +++ b/src/shape/point3D/sphere.ts @@ -1,4 +1,4 @@ -import { MeshPhongMaterial, SphereGeometry, Mesh } from '@antv/g-plugin-3d'; +import { MeshLambertMaterial, SphereGeometry, Mesh } from '@antv/g-plugin-3d'; import { applyStyle, getOrigin, toOpacityKey } from '../utils'; import { ShapeComponent as SC } from '../../runtime'; import { select } from '../../utils/selection'; @@ -28,7 +28,7 @@ export const Sphere: SC = (options, context) => { }); // create a material with Phong lighting model // @ts-ignore - context.sphereMaterial = new MeshPhongMaterial(device); + context.sphereMaterial = new MeshLambertMaterial(device); } return (points, value, defaults) => {