diff --git a/src/chart/radar/RadarPath.ts b/src/chart/radar/RadarPath.ts new file mode 100644 index 0000000000..23e307a811 --- /dev/null +++ b/src/chart/radar/RadarPath.ts @@ -0,0 +1,84 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { VectorArray } from 'zrender/src/core/vector'; +import { extend } from 'zrender/src/core/util'; +import Polygon, { PolygonShape } from 'zrender/src/graphic/shape/Polygon'; +import Polyline, { PolylineShape } from 'zrender/src/graphic/shape/Polyline'; + + +type RadarPathShape = PolygonShape | PolylineShape; + +export function isValidRadarPoint(point: VectorArray): boolean { + return !!point && !isNaN(point[0]) && !isNaN(point[1]); +} + +function getValidPoints(points: VectorArray[]): VectorArray[] { + if (!points) { + return points; + } + + let firstMissingIndex = -1; + for (let i = 0; i < points.length; i++) { + if (!isValidRadarPoint(points[i])) { + firstMissingIndex = i; + break; + } + } + + if (firstMissingIndex < 0) { + return points; + } + + const validPoints = points.slice(0, firstMissingIndex); + for (let i = firstMissingIndex + 1; i < points.length; i++) { + if (isValidRadarPoint(points[i])) { + validPoints.push(points[i]); + } + } + return validPoints; +} + +function getRenderableShape(shape: T): T { + const validPoints = getValidPoints(shape.points); + if (validPoints === shape.points) { + return shape; + } + + const renderableShape = extend({}, shape) as T; + renderableShape.points = validPoints; + return renderableShape; +} + +/** + * Keep missing points in the animated shape so dimensions stay aligned, but + * omit them at the final path-building boundary. This makes neighboring radar + * dimensions connect without sending NaN coordinates to the path builder. + */ +export class RadarPolyline extends Polyline { + buildPath(ctx: CanvasRenderingContext2D, shape: PolylineShape) { + super.buildPath(ctx, getRenderableShape(shape)); + } +} + +export class RadarPolygon extends Polygon { + buildPath(ctx: CanvasRenderingContext2D, shape: PolygonShape) { + super.buildPath(ctx, getRenderableShape(shape)); + } +} diff --git a/src/chart/radar/RadarView.ts b/src/chart/radar/RadarView.ts index a6d19eee33..a77bc42e1c 100644 --- a/src/chart/radar/RadarView.ts +++ b/src/chart/radar/RadarView.ts @@ -31,6 +31,7 @@ import { VectorArray } from 'zrender/src/core/vector'; import { setLabelStyle, getLabelStatesModels } from '../../label/labelStyle'; import ZRImage from 'zrender/src/graphic/Image'; import { saveOldStyle } from '../../animation/basicTransition'; +import { isValidRadarPoint, RadarPolygon, RadarPolyline } from './RadarPath'; type RadarSymbol = ReturnType & { __dimIdx: number @@ -84,10 +85,13 @@ class RadarView extends ChartView { // Simply rerender all symbolGroup.removeAll(); for (let i = 0; i < newPoints.length - 1; i++) { + if (!isValidRadarPoint(newPoints[i])) { + continue; + } const symbolPath = createSymbol(data, idx); if (symbolPath) { symbolPath.__dimIdx = i; - if (oldPoints[i]) { + if (oldPoints[i] && isValidRadarPoint(oldPoints[i])) { symbolPath.setPosition(oldPoints[i]); graphic[isInit ? 'initProps' : 'updateProps']( symbolPath, { @@ -106,7 +110,7 @@ class RadarView extends ChartView { function getInitialPoints(points: number[][]) { return zrUtil.map(points, function (pt) { - return [polar.cx, polar.cy]; + return isValidRadarPoint(pt) ? [polar.cx, polar.cy] : [NaN, NaN]; }); } data.diff(oldData) @@ -115,8 +119,8 @@ class RadarView extends ChartView { if (!points) { return; } - const polygon = new graphic.Polygon(); - const polyline = new graphic.Polyline(); + const polygon = new RadarPolygon(); + const polyline = new RadarPolyline(); const target = { shape: { points: points diff --git a/src/chart/radar/radarLayout.ts b/src/chart/radar/radarLayout.ts index 03db3e3eb8..765444b916 100644 --- a/src/chart/radar/radarLayout.ts +++ b/src/chart/radar/radarLayout.ts @@ -20,8 +20,8 @@ import * as zrUtil from 'zrender/src/core/util'; import GlobalModel from '../../model/Global'; import RadarSeriesModel, { SERIES_TYPE_RADAR } from './RadarSeries'; -import Radar from '../../coord/radar/Radar'; import { createSimpleOverallStageHandler } from '../../util/model'; +import { isValidRadarPoint } from './RadarPath'; type Point = number[]; @@ -42,19 +42,15 @@ function radarLayout(ecModel: GlobalModel) { data.each(data.mapDimension(axes[axisIndex].dim), function (val, dataIndex) { points[dataIndex] = points[dataIndex] || []; const point = coordSys.dataToPoint(val, axisIndex); - points[dataIndex][axisIndex] = isValidPoint(point) - ? point : getValueMissingPoint(coordSys); + points[dataIndex][axisIndex] = point; }); }); // Close polygon data.each(function (idx) { - // TODO - // Is it appropriate to connect to the next data when some data is missing? - // Or, should trade it like `connectNull` in line chart? const firstPoint = zrUtil.find(points[idx], function (point) { - return isValidPoint(point); - }) || getValueMissingPoint(coordSys); + return isValidRadarPoint(point); + }) || [NaN, NaN]; // Copy the first actual point to the end of the array points[idx].push(firstPoint.slice()); @@ -62,13 +58,3 @@ function radarLayout(ecModel: GlobalModel) { }); }); } - -function isValidPoint(point: Point) { - return !isNaN(point[0]) && !isNaN(point[1]); -} - -function getValueMissingPoint(coordSys: Radar): Point { - // It is error-prone to input [NaN, NaN] into polygon, polygon. - // (probably cause problem when refreshing or animating) - return [coordSys.cx, coordSys.cy]; -} \ No newline at end of file diff --git a/test/radar-missing-value.html b/test/radar-missing-value.html new file mode 100644 index 0000000000..94d9719874 --- /dev/null +++ b/test/radar-missing-value.html @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + +
+
+ + + + + diff --git a/test/ut/spec/series/radar.test.ts b/test/ut/spec/series/radar.test.ts new file mode 100644 index 0000000000..1ddb103ea5 --- /dev/null +++ b/test/ut/spec/series/radar.test.ts @@ -0,0 +1,196 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { EChartsType } from '@/src/echarts'; +import Group from 'zrender/src/graphic/Group'; +import Polygon from 'zrender/src/graphic/shape/Polygon'; +import Polyline from 'zrender/src/graphic/shape/Polyline'; +import { createChart, getViewGroup } from '../../core/utHelper'; + + +type Point = number[]; +type DrawCall = [string, ...number[]]; + +const INDICATORS = [ + { name: 'A', max: 100 }, + { name: 'B', max: 100 }, + { name: 'C', max: 100 }, + { name: 'D', max: 100 }, + { name: 'E', max: 100 }, + { name: 'F', max: 100 } +]; + +function setRadarData(chart: EChartsType, values: unknown[]) { + chart.setOption({ + animation: false, + radar: { + center: [200, 200], + radius: 150, + indicator: INDICATORS + }, + series: [{ + type: 'radar', + data: [{ value: values }] + }] + }); +} + +function getRadarGraphic(chart: EChartsType) { + const seriesGroup = getViewGroup(chart, 'series', 0); + const itemGroup = seriesGroup.childAt(0) as Group; + + return { + polyline: itemGroup.childAt(0) as Polyline, + polygon: itemGroup.childAt(1) as Polygon, + symbolGroup: itemGroup.childAt(2) as Group + }; +} + +function getLayout(chart: EChartsType): Point[] { + return getSeriesModel(chart).getData().getItemLayout(0); +} + +function getSeriesModel(chart: EChartsType) { + return (chart as any).getModel().getSeriesByIndex(0); +} + +function recordPath(path: Polyline | Polygon): DrawCall[] { + const calls: DrawCall[] = []; + const context = { + moveTo(x: number, y: number) { + calls.push(['moveTo', x, y]); + }, + lineTo(x: number, y: number) { + calls.push(['lineTo', x, y]); + }, + bezierCurveTo() { + throw new Error('Radar paths are not expected to be smoothed.'); + }, + closePath() { + calls.push(['closePath']); + } + }; + + path.buildPath(context as any, path.shape); + return calls; +} + +function expectedPolylineCalls(points: Point[], validIndices: number[]): DrawCall[] { + return validIndices.map((pointIndex, validIndex) => [ + validIndex === 0 ? 'moveTo' : 'lineTo', + points[pointIndex][0], + points[pointIndex][1] + ]); +} + + +describe('radar', function () { + + let chart: EChartsType; + + beforeEach(function () { + chart = createChart({ width: 400, height: 400 }); + }); + + afterEach(function () { + chart.dispose(); + }); + + it('connects neighboring values without coercing missing dimensions to zero', function () { + setRadarData(chart, [80, null, 40, 0, undefined, 20]); + + const points = getLayout(chart); + const graphic = getRadarGraphic(chart); + + expect(points).toHaveLength(INDICATORS.length + 1); + expect(points[1][0]).toBeNaN(); + expect(points[1][1]).toBeNaN(); + expect(points[4][0]).toBeNaN(); + expect(points[4][1]).toBeNaN(); + + // A real zero remains a drawable value at the radar center. + expect(points[3]).toEqual([200, 200]); + + // The last point closes the line at the first valid dimension. + expect(points[6]).toEqual(points[0]); + + const expectedCalls = expectedPolylineCalls(points, [0, 2, 3, 5, 6]); + expect(recordPath(graphic.polyline)).toEqual(expectedCalls); + expect(recordPath(graphic.polygon)).toEqual([ + ...expectedCalls, + ['closePath'] + ]); + expect(graphic.symbolGroup.children()).toHaveLength(4); + + const tooltip = getSeriesModel(chart).formatTooltip(0); + expect(tooltip.blocks.map((block: any) => block.value)).toEqual([ + 80, NaN, 40, 0, NaN, 20 + ]); + }); + + it('does not render geometry or symbols when every dimension is missing', function () { + setRadarData(chart, [null, undefined, NaN, '-', null, undefined]); + + const points = getLayout(chart); + const graphic = getRadarGraphic(chart); + + expect(points).toHaveLength(INDICATORS.length + 1); + points.forEach(function (point) { + expect(point[0]).toBeNaN(); + expect(point[1]).toBeNaN(); + }); + expect(recordPath(graphic.polyline)).toEqual([]); + expect(recordPath(graphic.polygon)).toEqual([]); + expect(graphic.symbolGroup.children()).toHaveLength(0); + }); + + it('rebuilds connected geometry when the missing dimensions change', function () { + setRadarData(chart, [80, null, 40, 30, undefined, 20]); + setRadarData(chart, [null, 0, undefined, 60, 50, 20]); + + const points = getLayout(chart); + const graphic = getRadarGraphic(chart); + + expect(points[0][0]).toBeNaN(); + expect(points[2][0]).toBeNaN(); + expect(points[1]).toEqual([200, 200]); + expect(points[6]).toEqual(points[1]); + + expect(recordPath(graphic.polyline)).toEqual( + expectedPolylineCalls(points, [1, 3, 4, 5, 6]) + ); + expect(graphic.symbolGroup.children()).toHaveLength(4); + }); + + it('keeps complete numeric radar geometry unchanged', function () { + setRadarData(chart, [100, 80, 60, 40, 20, 0]); + + const points = getLayout(chart); + const graphic = getRadarGraphic(chart); + + points.forEach(function (point) { + expect(Number.isFinite(point[0])).toBe(true); + expect(Number.isFinite(point[1])).toBe(true); + }); + expect(recordPath(graphic.polyline)).toEqual( + expectedPolylineCalls(points, [0, 1, 2, 3, 4, 5, 6]) + ); + expect(graphic.symbolGroup.children()).toHaveLength(6); + }); +});