diff --git a/src/chart/chord/ChordEdge.ts b/src/chart/chord/ChordEdge.ts new file mode 100644 index 0000000000..25001ddfb1 --- /dev/null +++ b/src/chart/chord/ChordEdge.ts @@ -0,0 +1,192 @@ +import type { PathProps, PathStyleProps } from 'zrender/src/graphic/Path'; +import type PathProxy from 'zrender/src/core/PathProxy'; +import { extend, isString } from 'zrender/src/core/util'; +import * as graphic from '../../util/graphic'; +import SeriesData from '../../data/SeriesData'; +import { GraphEdge } from '../../data/Graph'; +import type Model from '../../model/Model'; +import { getSectorCornerRadius } from '../helper/sectorHelper'; +import { saveOldStyle } from '../../animation/basicTransition'; +import ChordSeriesModel, { ChordEdgeItemOption, ChordEdgeLineStyleOption, ChordNodeItemOption } from './ChordSeries'; +import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; +import { getECData } from '../../util/innerStore'; + +export class ChordPathShape { + // Souce node, two points forming an arc + s1: [number, number] = [0, 0]; + s2: [number, number] = [0, 0]; + sStartAngle: number = 0; + sEndAngle: number = 0; + + // Target node, two points forming an arc + t1: [number, number] = [0, 0]; + t2: [number, number] = [0, 0]; + tStartAngle: number = 0; + tEndAngle: number = 0; + + cx: number = 0; + cy: number = 0; + // series.r0 of ChordSeries + r: number = 0; + + clockwise: boolean = true; +} + +interface ChordEdgePathProps extends PathProps { + shape?: Partial +} + +export class ChordEdge extends graphic.Path { + shape: ChordPathShape; + + constructor( + nodeData: SeriesData, + edgeData: SeriesData, + edgeIdx: number, + startAngle: number + ) { + super(); + getECData(this).dataType = 'edge'; + this.updateData(nodeData, edgeData, edgeIdx, startAngle, true); + } + + buildPath(ctx: PathProxy | CanvasRenderingContext2D, shape: ChordPathShape): void { + // Start from n11 + ctx.moveTo(shape.s1[0], shape.s1[1]); + + const ratio = 0.7; + const clockwise = shape.clockwise; + + // Draw the arc from n11 to n12 + ctx.arc(shape.cx, shape.cy, shape.r, shape.sStartAngle, shape.sEndAngle, !clockwise); + + // Bezier curve to cp1 and then to n21 + ctx.bezierCurveTo( + (shape.cx - shape.s2[0]) * ratio + shape.s2[0], + (shape.cy - shape.s2[1]) * ratio + shape.s2[1], + (shape.cx - shape.t1[0]) * ratio + shape.t1[0], + (shape.cy - shape.t1[1]) * ratio + shape.t1[1], + shape.t1[0], + shape.t1[1] + ); + + // Draw the arc from n21 to n22 + ctx.arc(shape.cx, shape.cy, shape.r, shape.tStartAngle, shape.tEndAngle, !clockwise); + + // Bezier curve back to cp2 and then to n11 + ctx.bezierCurveTo( + (shape.cx - shape.t2[0]) * ratio + shape.t2[0], + (shape.cy - shape.t2[1]) * ratio + shape.t2[1], + (shape.cx - shape.s1[0]) * ratio + shape.s1[0], + (shape.cy - shape.s1[1]) * ratio + shape.s1[1], + shape.s1[0], + shape.s1[1] + ); + + ctx.closePath(); + } + + updateData( + nodeData: SeriesData, + edgeData: SeriesData, + edgeIdx: number, + startAngle: number, + firstCreate?: boolean + ): void { + const seriesModel = nodeData.hostModel as ChordSeriesModel; + const edge = edgeData.graph.getEdgeByIndex(edgeIdx); + const layout = edge.getLayout(); + const itemModel = edge.node1.getModel(); + const edgeModel = edgeData.getItemModel(edge.dataIndex); + const lineStyle = edgeModel.getModel('lineStyle'); + const emphasisModel = edgeModel.getModel('emphasis'); + const focus = emphasisModel.get('focus'); + + const shape: ChordPathShape = extend( + getSectorCornerRadius(itemModel.getModel('itemStyle'), layout, true), + layout + ); + + const el = this; + + // Ignore NaN data. + if (isNaN(shape.sStartAngle) || isNaN(shape.tStartAngle)) { + // Use NaN shape to avoid drawing shape. + el.setShape(shape); + return; + } + + if (firstCreate) { + el.setShape(shape); + applyEdgeFill(el, edge, nodeData, lineStyle); + } + else { + saveOldStyle(el); + + applyEdgeFill(el, edge, nodeData, lineStyle); + graphic.updateProps(el, { + shape: shape + }, seriesModel, edgeIdx); + } + + toggleHoverEmphasis( + this, + focus === 'adjacency' + ? edge.getAdjacentDataIndices() + : focus, + emphasisModel.get('blurScope'), + emphasisModel.get('disabled') + ); + + setStatesStylesFromModel(el, edgeModel, 'lineStyle'); + + edgeData.setItemGraphicEl(edge.dataIndex, el); + } +} + +function applyEdgeFill( + edgeShape: ChordEdge, + edge: GraphEdge, + nodeData: SeriesData, + lineStyleModel: Model +) { + const node1 = edge.node1; + const node2 = edge.node2; + const edgeStyle = edgeShape.style as PathStyleProps; + + edgeShape.setStyle(lineStyleModel.getLineStyle()); + + const color = lineStyleModel.get('color'); + switch (color) { + case 'source': + // TODO: use visual and node1.getVisual('color'); + edgeStyle.fill = nodeData.getItemVisual(node1.dataIndex, 'style').fill; + edgeStyle.decal = node1.getVisual('style').decal; + break; + case 'target': + edgeStyle.fill = nodeData.getItemVisual(node2.dataIndex, 'style').fill; + edgeStyle.decal = node2.getVisual('style').decal; + break; + case 'gradient': + const sourceColor = nodeData.getItemVisual(node1.dataIndex, 'style').fill; + const targetColor = nodeData.getItemVisual(node2.dataIndex, 'style').fill; + if (isString(sourceColor) && isString(targetColor)) { + // Gradient direction is perpendicular to the mid-angles + // of source and target nodes. + const shape = edgeShape.shape; + const sMidX = (shape.s1[0] + shape.s2[0]) / 2; + const sMidY = (shape.s1[1] + shape.s2[1]) / 2; + const tMidX = (shape.t1[0] + shape.t2[0]) / 2; + const tMidY = (shape.t1[1] + shape.t2[1]) / 2; + edgeStyle.fill = new graphic.LinearGradient( + sMidX, sMidY, tMidX, tMidY, + [ + { offset: 0, color: sourceColor }, + { offset: 1, color: targetColor } + ], + true + ); + } + break; + } +} diff --git a/src/chart/chord/ChordPiece.ts b/src/chart/chord/ChordPiece.ts new file mode 100644 index 0000000000..0c502eb1c4 --- /dev/null +++ b/src/chart/chord/ChordPiece.ts @@ -0,0 +1,169 @@ +import { extend, retrieve3 } from 'zrender/src/core/util'; +import * as graphic from '../../util/graphic'; +import SeriesData from '../../data/SeriesData'; +import { getSectorCornerRadius } from '../helper/sectorHelper'; +import ChordSeriesModel, { ChordNodeItemOption } from './ChordSeries'; +import type Model from '../../model/Model'; +import type { GraphNode } from '../../data/Graph'; +import { getLabelStatesModels, setLabelStyle } from '../../label/labelStyle'; +import type { BuiltinTextPosition } from 'zrender/src/core/types'; +import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; +import { getECData } from '../../util/innerStore'; + +export default class ChordPiece extends graphic.Sector { + + constructor(data: SeriesData, idx: number, startAngle: number) { + super(); + getECData(this).dataType = 'node'; + this.z2 = 2; + + const text = new graphic.Text(); + + this.setTextContent(text); + + this.updateData(data, idx, startAngle, true); + } + + updateData(data: SeriesData, idx: number, startAngle?: number, firstCreate?: boolean): void { + const sector = this; + const node = data.graph.getNodeByIndex(idx); + + const seriesModel = data.hostModel as ChordSeriesModel; + const itemModel = node.getModel(); + const emphasisModel = itemModel.getModel('emphasis'); + + // layout position is the center of the sector + const layout = data.getItemLayout(idx) as graphic.Sector['shape']; + const shape: graphic.Sector['shape'] = extend( + getSectorCornerRadius(itemModel.getModel('itemStyle'), layout, true), + layout + ); + + const el = this; + + // Ignore NaN data. + if (isNaN(shape.startAngle)) { + // Use NaN shape to avoid drawing shape. + el.setShape(shape); + return; + } + + if (firstCreate) { + el.setShape(shape); + } + else { + graphic.updateProps(el, { + shape: shape + }, seriesModel, idx); + } + + const sectorShape = extend( + getSectorCornerRadius( + itemModel.getModel('itemStyle'), + layout, + true + ), + layout + ); + sector.setShape(sectorShape); + sector.useStyle(data.getItemVisual(idx, 'style')); + setStatesStylesFromModel(sector, itemModel); + + this._updateLabel(seriesModel, itemModel, node); + + data.setItemGraphicEl(idx, el); + setStatesStylesFromModel(el, itemModel, 'itemStyle'); + + // Add focus/blur states handling + const focus = emphasisModel.get('focus'); + toggleHoverEmphasis( + this, + focus === 'adjacency' + ? node.getAdjacentDataIndices() + : focus, + emphasisModel.get('blurScope'), + emphasisModel.get('disabled') + ); + } + + protected _updateLabel( + seriesModel: ChordSeriesModel, + itemModel: Model, + node: GraphNode + ) { + const label = this.getTextContent(); + const layout = node.getLayout(); + const midAngle = (layout.startAngle + layout.endAngle) / 2; + const dx = Math.cos(midAngle); + const dy = Math.sin(midAngle); + + const normalLabelModel = itemModel.getModel('label'); + label.ignore = !normalLabelModel.get('show'); + + // Set label style + const labelStateModels = getLabelStatesModels(itemModel); + const style = node.getVisual('style'); + setLabelStyle( + label, + labelStateModels, + { + labelFetcher: { + getFormattedLabel(dataIndex, stateName, dataType, labelDimIndex, formatter, extendParams) { + return seriesModel.getFormattedLabel( + dataIndex, stateName, 'node', + labelDimIndex, + // ensure edgeLabel formatter is provided + // to prevent the inheritance from `label.formatter` of the series + retrieve3( + formatter, + labelStateModels.normal && labelStateModels.normal.get('formatter'), + itemModel.get('name') + ), + extendParams + ); + } + }, + labelDataIndex: node.dataIndex, + defaultText: node.dataIndex + '', + inheritColor: style.fill, + defaultOpacity: style.opacity, + defaultOutsidePosition: 'startArc' as BuiltinTextPosition + } + ); + + // Set label position + const labelPosition = normalLabelModel.get('position') || 'outside'; + const labelPadding = normalLabelModel.get('distance') || 0; + + let r; + if (labelPosition === 'outside') { + r = layout.r + labelPadding; + } + else { + r = (layout.r + layout.r0) / 2; + } + + this.textConfig = { + inside: labelPosition !== 'outside' + }; + + const align = labelPosition !== 'outside' + ? normalLabelModel.get('align') || 'center' + : (dx > 0 ? 'left' : 'right'); + + const verticalAlign = labelPosition !== 'outside' + ? normalLabelModel.get('verticalAlign') || 'middle' + : (dy > 0 ? 'top' : 'bottom'); + + label.attr({ + x: dx * r + layout.cx, + y: dy * r + layout.cy, + rotation: 0, + style: { + align, + verticalAlign + } + }); + } +} + diff --git a/src/chart/chord/ChordSeries.ts b/src/chart/chord/ChordSeries.ts new file mode 100644 index 0000000000..568f5b96d7 --- /dev/null +++ b/src/chart/chord/ChordSeries.ts @@ -0,0 +1,340 @@ +/* +* 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 { + SeriesOption, + SeriesOnCartesianOptionMixin, + SeriesOnPolarOptionMixin, + SeriesOnCalendarOptionMixin, + SeriesOnGeoOptionMixin, + SeriesOnSingleOptionMixin, + OptionDataValue, + RoamOptionMixin, + SeriesLabelOption, + ItemStyleOption, + LineStyleOption, + SymbolOptionMixin, + BoxLayoutOptionMixin, + CircleLayoutOptionMixin, + SeriesLineLabelOption, + StatesOptionMixin, + GraphEdgeItemObject, + OptionDataValueNumeric, + CallbackDataParams, + DefaultEmphasisFocus +} from '../../util/types'; +import Model from '../../model/Model'; +import SeriesModel from '../../model/Series'; +import GlobalModel from '../../model/Global'; +import SeriesData from '../../data/SeriesData'; +import createGraphFromNodeEdge from '../helper/createGraphFromNodeEdge'; +import Graph from '../../data/Graph'; +import { LineDataVisual } from '../../visual/commonVisualTypes'; +import { createTooltipMarkup } from '../../component/tooltip/tooltipMarkup'; +import LegendVisualProvider from '../../visual/LegendVisualProvider'; +import * as zrUtil from 'zrender/src/core/util'; + +interface ExtraEmphasisState { + /** + * For focus on nodes: + * - self: Focus self node, and all edges connected to it. + * - adjacency: Focus self nodes and two edges (source and target) + * connected to the focused node. + * + * For focus on edges: + * - self: Focus self edge, and all nodes connected to it. + * - adjacency: Focus self edge and all edges connected to it and all + * nodes connected to these edges. + */ + focus?: DefaultEmphasisFocus | 'adjacency' +} + +interface ChordStatesMixin { + emphasis?: ExtraEmphasisState +} + +interface ChordEdgeStatesMixin { + emphasis?: ExtraEmphasisState +} + +type ChordDataValue = OptionDataValue | OptionDataValue[]; + +export interface ChordItemStyleOption extends ItemStyleOption { + borderRadius?: (number | string)[] | number | string +} + +export interface ChordNodeStateOption { + itemStyle?: ChordItemStyleOption + label?: ChordNodeLabelOption +} + +export interface ChordNodeItemOption extends ChordNodeStateOption, + StatesOptionMixin { + + id?: string + name?: string + value?: ChordDataValue +} + +export interface ChordEdgeLineStyleOption extends LineStyleOption { + curveness?: number +} + +export interface ChordNodeLabelOption extends Omit, 'position'> { + silent?: boolean + position?: SeriesLabelOption['position'] | 'outside' +} + +export interface ChordEdgeStateOption { + lineStyle?: ChordEdgeLineStyleOption + label?: SeriesLineLabelOption +} + +export interface ChordEdgeItemOption extends ChordEdgeStateOption, + StatesOptionMixin, + GraphEdgeItemObject { + + value?: number +} + +export interface ChordSeriesOption + extends SeriesOption, ChordStatesMixin>, + SeriesOnCartesianOptionMixin, SeriesOnPolarOptionMixin, SeriesOnCalendarOptionMixin, + SeriesOnGeoOptionMixin, SeriesOnSingleOptionMixin, + SymbolOptionMixin, + RoamOptionMixin, + BoxLayoutOptionMixin, + CircleLayoutOptionMixin +{ + type?: 'chord' + + coordinateSystem?: 'none' + + legendHoverLink?: boolean + + clockwise?: boolean + startAngle?: number + endAngle?: number | 'auto' + padAngle?: number + minAngle?: number + + data?: (ChordNodeItemOption | ChordDataValue)[] + nodes?: (ChordNodeItemOption | ChordDataValue)[] + + edges?: ChordEdgeItemOption[] + links?: ChordEdgeItemOption[] + + edgeLabel?: SeriesLineLabelOption + label?: ChordNodeLabelOption + + itemStyle?: ChordItemStyleOption + lineStyle?: ChordEdgeLineStyleOption + + emphasis?: { + focus?: Exclude['focus'] + scale?: boolean | number + label?: SeriesLabelOption + edgeLabel?: SeriesLabelOption + itemStyle?: ItemStyleOption + lineStyle?: LineStyleOption + } + + blur?: { + label?: SeriesLabelOption + edgeLabel?: SeriesLabelOption + itemStyle?: ItemStyleOption + lineStyle?: LineStyleOption + } + + select?: { + label?: SeriesLabelOption + edgeLabel?: SeriesLabelOption + itemStyle?: ItemStyleOption + lineStyle?: LineStyleOption + } +} + +class ChordSeriesModel extends SeriesModel { + + static type = 'series.chord'; + readonly type = ChordSeriesModel.type; + + init(option: ChordSeriesOption) { + super.init.apply(this, arguments as any); + this.fillDataTextStyle(option.edges || option.links); + + // Enable legend selection for each data item + this.legendVisualProvider = new LegendVisualProvider( + zrUtil.bind(this.getData, this), zrUtil.bind(this.getRawData, this) + ); + } + + mergeOption(option: ChordSeriesOption) { + super.mergeOption.apply(this, arguments as any); + this.fillDataTextStyle(option.edges || option.links); + } + + getInitialData(option: ChordSeriesOption, ecModel: GlobalModel): SeriesData { + const edges = option.edges || option.links || []; + const nodes = option.data || option.nodes || []; + + if (nodes && edges) { + const graph = createGraphFromNodeEdge(nodes as ChordNodeItemOption[], edges, this, true, beforeLink); + return graph.data; + } + + function beforeLink(nodeData: SeriesData, edgeData: SeriesData) { + // TODO Inherit resolveParentPath by default in Model#getModel? + const oldGetModel = Model.prototype.getModel; + function newGetModel(this: Model, path: any, parentModel?: Model) { + const model = oldGetModel.call(this, path, parentModel); + model.resolveParentPath = resolveParentPath; + return model; + } + + edgeData.wrapMethod('getItemModel', function (model: Model) { + model.resolveParentPath = resolveParentPath; + model.getModel = newGetModel; + return model; + }); + + function resolveParentPath(this: Model, pathArr: readonly string[]): string[] { + if (pathArr && (pathArr[0] === 'label' || pathArr[1] === 'label')) { + const newPathArr = pathArr.slice(); + if (pathArr[0] === 'label') { + newPathArr[0] = 'edgeLabel'; + } + else if (pathArr[1] === 'label') { + newPathArr[1] = 'edgeLabel'; + } + return newPathArr; + } + return pathArr as string[]; + } + } + } + + getGraph(): Graph { + return this.getData().graph; + } + + getEdgeData() { + return this.getGraph().edgeData as SeriesData; + } + + formatTooltip( + dataIndex: number, + multipleSeries: boolean, + dataType: string + ) { + const params = this.getDataParams(dataIndex, dataType as 'node' | 'edge'); + + if (dataType === 'edge') { + const nodeData = this.getData(); + const edge = nodeData.graph.getEdgeByIndex(dataIndex); + const sourceName = nodeData.getName(edge.node1.dataIndex); + const targetName = nodeData.getName(edge.node2.dataIndex); + + const nameArr = []; + sourceName != null && nameArr.push(sourceName); + targetName != null && nameArr.push(targetName); + + return createTooltipMarkup('nameValue', { + name: nameArr.join(' > '), + value: params.value, + noValue: params.value == null + }); + } + // dataType === 'node' or empty + return createTooltipMarkup('nameValue', { + name: params.name, + value: params.value, + noValue: params.value == null + }); + } + + getDataParams(dataIndex: number, dataType: 'node' | 'edge') { + const params = super.getDataParams(dataIndex, dataType); + if (dataType === 'node') { + const nodeData = this.getData(); + const node = this.getGraph().getNodeByIndex(dataIndex); + // Set name if not already set + if (params.name == null) { + params.name = nodeData.getName(dataIndex); + } + // Set value if not already set + if (params.value == null) { + const nodeValue = node.getLayout().value; + params.value = nodeValue; + } + } + return params; + } + + static defaultOption: ChordSeriesOption = { + // zlevel: 0, + z: 2, + + coordinateSystem: 'none', + + legendHoverLink: true, + colorBy: 'data', + + left: 0, + top: 0, + right: 0, + bottom: 0, + width: null, + height: null, + + center: ['50%', '50%'], + radius: ['70%', '80%'], + clockwise: true, + + startAngle: 90, + endAngle: 'auto', + minAngle: 0, + padAngle: 3, + + itemStyle: { + borderRadius: [0, 0, 5, 5] + }, + + lineStyle: { + width: 0, + color: 'source', + opacity: 0.2 + }, + + label: { + show: true, + position: 'outside', + distance: 5 + }, + + emphasis: { + focus: 'adjacency', + lineStyle: { + opacity: 0.5 + } + } + }; +} + +export default ChordSeriesModel; diff --git a/src/chart/chord/ChordView.ts b/src/chart/chord/ChordView.ts new file mode 100644 index 0000000000..4434ebe427 --- /dev/null +++ b/src/chart/chord/ChordView.ts @@ -0,0 +1,153 @@ +/* +* 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 * as graphic from '../../util/graphic'; +import ChartView from '../../view/Chart'; +import GlobalModel from '../../model/Global'; +import ExtensionAPI from '../../core/ExtensionAPI'; +import SeriesData from '../../data/SeriesData'; +import ChordSeriesModel from './ChordSeries'; +import ChordPiece from './ChordPiece'; +import { ChordEdge } from './ChordEdge'; +import { parsePercent } from '../../util/number'; +import { getECData } from '../../util/innerStore'; + +const RADIAN = Math.PI / 180; + +class ChordView extends ChartView { + + static readonly type = 'chord'; + readonly type: string = ChordView.type; + + private _data: SeriesData; + private _edgeData: SeriesData; + + init(ecModel: GlobalModel, api: ExtensionAPI) { + } + + render(seriesModel: ChordSeriesModel, ecModel: GlobalModel, api: ExtensionAPI) { + const data = seriesModel.getData(); + const oldData = this._data; + const group = this.group; + + const startAngle = -seriesModel.get('startAngle') * RADIAN; + + data.diff(oldData) + .add((newIdx) => { + /* Consider the case when there are only two nodes A and B, + * and there is a link between A and B. + * At first, they are both disselected from legend. And then + * when A is selected, A will go into `add` method. But since + * there are no edges to be displayed, A should not be added. + * So we should only add A when layout is defined. + */ + + const layout = data.getItemLayout(newIdx); + if (layout) { + const el = new ChordPiece(data, newIdx, startAngle); + getECData(el).dataIndex = newIdx; + group.add(el); + } + }) + + .update((newIdx, oldIdx) => { + let el = oldData.getItemGraphicEl(oldIdx) as ChordPiece; + const layout = data.getItemLayout(newIdx); + + /* Consider the case when there are only two nodes A and B, + * and there is a link between A and B. + * and when A is disselected from legend, there should be + * nothing to display. But in the `data.diff` method, B will go + * into `update` method and having no layout. + * In this case, we need to remove B. + */ + if (!layout) { + el && graphic.removeElementWithFadeOut(el, seriesModel, oldIdx); + return; + } + + if (!el) { + el = new ChordPiece(data, newIdx, startAngle); + } + else { + el.updateData(data, newIdx, startAngle); + } + group.add(el); + }) + + .remove(oldIdx => { + const el = oldData.getItemGraphicEl(oldIdx) as ChordPiece; + el && graphic.removeElementWithFadeOut(el, seriesModel, oldIdx); + }) + + .execute(); + + if (!oldData) { + const center = seriesModel.get('center'); + this.group.scaleX = 0.01; + this.group.scaleY = 0.01; + this.group.originX = parsePercent(center[0], api.getWidth()); + this.group.originY = parsePercent(center[1], api.getHeight()); + graphic.initProps(this.group, { + scaleX: 1, + scaleY: 1 + }, seriesModel); + } + + this._data = data; + + this.renderEdges(seriesModel, startAngle); + } + + renderEdges(seriesModel: ChordSeriesModel, startAngle: number) { + const nodeData = seriesModel.getData(); + const edgeData = seriesModel.getEdgeData(); + const oldData = this._edgeData; + const group = this.group; + + edgeData.diff(oldData) + .add(function (newIdx) { + const el = new ChordEdge(nodeData, edgeData, newIdx, startAngle); + getECData(el).dataIndex = newIdx; + group.add(el); + }) + + .update(function (newIdx, oldIdx) { + const el = oldData.getItemGraphicEl(oldIdx) as ChordEdge; + el.updateData(nodeData, edgeData, newIdx, startAngle); + group.add(el); + }) + + .remove(function (oldIdx) { + const el = oldData.getItemGraphicEl(oldIdx) as ChordEdge; + el && graphic.removeElementWithFadeOut(el, seriesModel, oldIdx); + }) + + .execute(); + + this._edgeData = edgeData; + } + + dispose() { + + } +} + + +export default ChordView; diff --git a/src/chart/chord/chordLayout.ts b/src/chart/chord/chordLayout.ts new file mode 100644 index 0000000000..cd9b894416 --- /dev/null +++ b/src/chart/chord/chordLayout.ts @@ -0,0 +1,269 @@ +/* +* 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 { normalizeArcAngles } from 'zrender/src/core/PathProxy'; +import GlobalModel from '../../model/Global'; +import ChordSeriesModel from './ChordSeries'; +import ExtensionAPI from '../../core/ExtensionAPI'; +import { getCircleLayout } from '../../util/layout'; +import SeriesModel from '../../model/Series'; +import { CircleLayoutOptionMixin, SeriesOption } from '../../util/types'; + +const RADIAN = Math.PI / 180; + +export default function chordCircularLayout(ecModel: GlobalModel, api: ExtensionAPI) { + ecModel.eachSeriesByType('chord', function (seriesModel: ChordSeriesModel) { + chordLayout(seriesModel, api); + }); +} + +function chordLayout(seriesModel: ChordSeriesModel, api: ExtensionAPI) { + const nodeData = seriesModel.getData(); + const nodeGraph = nodeData.graph; + const edgeData = seriesModel.getEdgeData(); + const edgeCount = edgeData.count(); + + if (!edgeCount) { + return; + } + + const { cx, cy, r, r0 } = getCircleLayout( + seriesModel as unknown as SeriesModel>, + api + ); + + let padAngle = Math.max((seriesModel.get('padAngle') || 0) * RADIAN, 0); + let minAngle = Math.max((seriesModel.get('minAngle') || 0) * RADIAN, 0); + const startAngle = -seriesModel.get('startAngle') * RADIAN; + const endAngle = startAngle + Math.PI * 2; + const clockwise = seriesModel.get('clockwise'); + const dir = clockwise ? 1 : -1; + + // Normalize angles + const angles = [startAngle, endAngle]; + normalizeArcAngles(angles, !clockwise); + const [normalizedStartAngle, normalizedEndAngle] = angles; + const totalAngle = normalizedEndAngle - normalizedStartAngle; + + const allZero = nodeData.getSum('value') === 0 && edgeData.getSum('value') === 0; + + // Sum of each node's edge values + const nodeValues: number[] = []; + let renderedNodeCount = 0; + nodeGraph.eachEdge(function (edge) { + // All links use the same value 1 when allZero is true + const value = allZero ? 1 : edge.getValue('value') as number; + if (allZero && (value > 0 || minAngle)) { + // When allZero is true, angle is in direct proportion to number + // of links both in and out of the node. + renderedNodeCount += 2; + } + const node1Index = edge.node1.dataIndex; + const node2Index = edge.node2.dataIndex; + nodeValues[node1Index] = (nodeValues[node1Index] || 0) + value; + nodeValues[node2Index] = (nodeValues[node2Index] || 0) + value; + }); + + // Update nodeValues with data.value if exists + let nodeValueSum = 0; + nodeGraph.eachNode(node => { + const dataValue = node.getValue('value') as number; + if (!isNaN(dataValue)) { + nodeValues[node.dataIndex] = Math.max(dataValue, nodeValues[node.dataIndex] || 0); + } + if (!allZero && (nodeValues[node.dataIndex] > 0 || minAngle)) { + // When allZero is false, angle is in direct proportion to node's + // value + renderedNodeCount++; + } + nodeValueSum += nodeValues[node.dataIndex] || 0; + }); + + if (renderedNodeCount === 0 || nodeValueSum === 0) { + return; + } + if (padAngle * renderedNodeCount >= Math.abs(totalAngle)) { + // Not enough angle to render the pad, minAngle has higher priority, and padAngle takes the rest + padAngle = Math.max(0, (Math.abs(totalAngle) - minAngle * renderedNodeCount) / renderedNodeCount); + } + if ((padAngle + minAngle) * renderedNodeCount >= Math.abs(totalAngle)) { + // Not enough angle to render the minAngle, so ignore the minAngle + minAngle = (Math.abs(totalAngle) - padAngle * renderedNodeCount) / renderedNodeCount; + } + + const unitAngle = (totalAngle - padAngle * renderedNodeCount * dir) + / nodeValueSum; + + let totalDeficit = 0; // sum of deficits of nodes with span < minAngle + let totalSurplus = 0; // sum of (spans - minAngle) of nodes with span > minAngle + let totalSurplusSpan = 0; // sum of spans of nodes with span > minAngle + let minSurplus = Infinity; // min of (spans - minAngle) of nodes with span > minAngle + nodeGraph.eachNode(node => { + const value = nodeValues[node.dataIndex] || 0; + const spanAngle = unitAngle * (nodeValueSum ? value : 1) * dir; + if (Math.abs(spanAngle) < minAngle) { + totalDeficit += minAngle - Math.abs(spanAngle); + } + else { + minSurplus = Math.min(minSurplus, Math.abs(spanAngle) - minAngle); + totalSurplus += Math.abs(spanAngle) - minAngle; + totalSurplusSpan += Math.abs(spanAngle); + } + node.setLayout({ + angle: spanAngle, + value: value + }); + }); + + let surplusAsMuchAsPossible = false; + if (totalDeficit > totalSurplus) { + // Not enough angle to spread the nodes, scale all + const scale = totalDeficit / totalSurplus; + nodeGraph.eachNode(node => { + const spanAngle = node.getLayout().angle; + if (Math.abs(spanAngle) >= minAngle) { + node.setLayout({ + angle: spanAngle * scale, + ratio: scale + }, true); + } + else { + node.setLayout({ + angle: minAngle, + ratio: minAngle === 0 ? 1 : spanAngle / minAngle + }, true); + } + }); + } + else { + // For example, if totalDeficit is 60 degrees and totalSurplus is 70 + // degrees but one of the sector can only reduced by 1 degree, + // if we decrease it with the ratio of value to other surplused nodes, + // it will have smaller angle than minAngle itself. + // So we need to borrow some angle from other nodes. + nodeGraph.eachNode(node => { + if (surplusAsMuchAsPossible) { + return; + } + const spanAngle = node.getLayout().angle; + const borrowRatio = Math.min(spanAngle / totalSurplusSpan, 1); + const borrowAngle = borrowRatio * totalDeficit; + if (spanAngle - borrowAngle < minAngle) { + // It will have less than minAngle after borrowing + surplusAsMuchAsPossible = true; + } + }); + } + + let restDeficit = totalDeficit; + nodeGraph.eachNode(node => { + if (restDeficit <= 0) { + return; + } + + const spanAngle = node.getLayout().angle; + if (spanAngle > minAngle && minAngle > 0) { + const borrowRatio = surplusAsMuchAsPossible + ? 1 + : Math.min(spanAngle / totalSurplusSpan, 1); + const maxBorrowAngle = spanAngle - minAngle; + const borrowAngle = Math.min(maxBorrowAngle, + Math.min(restDeficit, totalDeficit * borrowRatio) + ); + restDeficit -= borrowAngle; + node.setLayout({ + angle: spanAngle - borrowAngle, + ratio: (spanAngle - borrowAngle) / spanAngle + }, true); + } + else if (minAngle > 0) { + node.setLayout({ + angle: minAngle, + ratio: spanAngle === 0 ? 1 : minAngle / spanAngle + }, true); + } + }); + + let angle = normalizedStartAngle; + const edgeAccAngle: number[] = []; + nodeGraph.eachNode(node => { + const spanAngle = Math.max(node.getLayout().angle, minAngle); + node.setLayout({ + cx, + cy, + r0, + r, + startAngle: angle, + endAngle: angle + spanAngle * dir, + clockwise + }, true); + edgeAccAngle[node.dataIndex] = angle; + angle += (spanAngle + padAngle) * dir; + }); + + nodeGraph.eachEdge(edge => { + const value = allZero ? 1 : edge.getValue('value') as number; + const spanAngle = unitAngle * (nodeValueSum ? value : 1) * dir; + + const node1Index = edge.node1.dataIndex; + const sStartAngle = edgeAccAngle[node1Index] || 0; + const sSpan = Math.abs((edge.node1.getLayout().ratio || 1) * spanAngle); + const sEndAngle = sStartAngle + sSpan * dir; + const s1 = [ + cx + r0 * Math.cos(sStartAngle), + cy + r0 * Math.sin(sStartAngle) + ]; + const s2 = [ + cx + r0 * Math.cos(sEndAngle), + cy + r0 * Math.sin(sEndAngle) + ]; + + const node2Index = edge.node2.dataIndex; + const tStartAngle = edgeAccAngle[node2Index] || 0; + const tSpan = Math.abs((edge.node2.getLayout().ratio || 1) * spanAngle); + const tEndAngle = tStartAngle + tSpan * dir; + const t1 = [ + cx + r0 * Math.cos(tStartAngle), + cy + r0 * Math.sin(tStartAngle) + ]; + const t2 = [ + cx + r0 * Math.cos(tEndAngle), + cy + r0 * Math.sin(tEndAngle) + ]; + + edge.setLayout({ + s1, + s2, + sStartAngle, + sEndAngle, + t1, + t2, + tStartAngle, + tEndAngle, + cx, + cy, + r: r0, + value, + clockwise + }); + + edgeAccAngle[node1Index] = sEndAngle; + edgeAccAngle[node2Index] = tEndAngle; + }); +} diff --git a/src/chart/chord/install.ts b/src/chart/chord/install.ts new file mode 100644 index 0000000000..f657e5216d --- /dev/null +++ b/src/chart/chord/install.ts @@ -0,0 +1,33 @@ +/* +* 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 { EChartsExtensionInstallRegisters } from '../../extension'; +import ChordView from './ChordView'; +import ChordSeriesModel from './ChordSeries'; +import chordLayout from './chordLayout'; +import dataFilter from '../../processor/dataFilter'; + +export function install(registers: EChartsExtensionInstallRegisters) { + registers.registerChartView(ChordView); + registers.registerSeriesModel(ChordSeriesModel); + + registers.registerLayout(registers.PRIORITY.VISUAL.POST_CHART_LAYOUT, chordLayout); + // Add data filter processor + registers.registerProcessor(dataFilter('chord')); +} \ No newline at end of file diff --git a/src/chart/funnel/funnelLayout.ts b/src/chart/funnel/funnelLayout.ts index 4d433c1b1e..d1f1708724 100644 --- a/src/chart/funnel/funnelLayout.ts +++ b/src/chart/funnel/funnelLayout.ts @@ -25,15 +25,6 @@ import SeriesData from '../../data/SeriesData'; import GlobalModel from '../../model/Global'; import { isFunction } from 'zrender/src/core/util'; -function getViewRect(seriesModel: FunnelSeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - function getSortedIndices(data: SeriesData, sort: FunnelSeriesOption['sort']) { const valueDim = data.mapDimension('value'); const valueArr = data.mapArray(valueDim, function (val: number) { @@ -251,7 +242,7 @@ export default function funnelLayout(ecModel: GlobalModel, api: ExtensionAPI) { const data = seriesModel.getData(); const valueDim = data.mapDimension('value'); const sort = seriesModel.get('sort'); - const viewRect = getViewRect(seriesModel, api); + const viewRect = layout.getViewRect(seriesModel, api); const orient = seriesModel.get('orient'); const viewWidth = viewRect.width; const viewHeight = viewRect.height; diff --git a/src/chart/pie/PieView.ts b/src/chart/pie/PieView.ts index 04a81f1e6b..ea87333ba1 100644 --- a/src/chart/pie/PieView.ts +++ b/src/chart/pie/PieView.ts @@ -25,7 +25,8 @@ import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states import ChartView from '../../view/Chart'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import { Payload, ColorString } from '../../util/types'; +import { Payload, ColorString, CircleLayoutOptionMixin, SeriesOption } from '../../util/types'; +import SeriesModel from '../../model/Series'; import SeriesData from '../../data/SeriesData'; import PieSeriesModel, {PieDataItemOption} from './PieSeries'; import labelLayout from './labelLayout'; @@ -33,7 +34,8 @@ import { setLabelLineStyle, getLabelLineStatesModels } from '../../label/labelGu import { setLabelStyle, getLabelStatesModels } from '../../label/labelStyle'; import { getSectorCornerRadius } from '../helper/sectorHelper'; import { saveOldStyle } from '../../animation/basicTransition'; -import { getBasicPieLayout, getSeriesLayoutData } from './pieLayout'; +import { getSeriesLayoutData } from './pieLayout'; +import { getCircleLayout } from '../../util/layout'; /** * Piece of pie including Sector, Label, LabelLine @@ -262,7 +264,12 @@ class PieView extends ChartView { if (data.count() === 0 && seriesModel.get('showEmptyCircle')) { const layoutData = getSeriesLayoutData(seriesModel); const sector = new graphic.Sector({ - shape: extend(getBasicPieLayout(seriesModel, api), layoutData) + shape: extend( + getCircleLayout( + seriesModel as unknown as SeriesModel>, api + ), + layoutData + ) }); sector.useStyle(seriesModel.getModel('emptyCircleStyle').getItemStyle()); this._emptyCircleSector = sector; diff --git a/src/chart/pie/pieLayout.ts b/src/chart/pie/pieLayout.ts index 4ec992ec4b..a94eb42fcf 100644 --- a/src/chart/pie/pieLayout.ts +++ b/src/chart/pie/pieLayout.ts @@ -17,69 +17,19 @@ * under the License. */ -import { parsePercent, linearMap } from '../../util/number'; +import { linearMap } from '../../util/number'; import * as layout from '../../util/layout'; -import * as zrUtil from 'zrender/src/core/util'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import PieSeriesModel from './PieSeries'; -import { SectorShape } from 'zrender/src/graphic/shape/Sector'; import { normalizeArcAngles } from 'zrender/src/core/PathProxy'; import { makeInner } from '../../util/model'; +import SeriesModel from '../../model/Series'; +import { CircleLayoutOptionMixin, SeriesOption } from '../../util/types'; const PI2 = Math.PI * 2; const RADIAN = Math.PI / 180; -function getViewRect(seriesModel: PieSeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - -export function getBasicPieLayout(seriesModel: PieSeriesModel, api: ExtensionAPI): - Pick { - const viewRect = getViewRect(seriesModel, api); - - // center can be string or number when coordinateSystem is specified - let center = seriesModel.get('center'); - let radius = seriesModel.get('radius'); - - if (!zrUtil.isArray(radius)) { - radius = [0, radius]; - } - const width = parsePercent(viewRect.width, api.getWidth()); - const height = parsePercent(viewRect.height, api.getHeight()); - const size = Math.min(width, height); - const r0 = parsePercent(radius[0], size / 2); - const r = parsePercent(radius[1], size / 2); - - let cx: number; - let cy: number; - const coordSys = seriesModel.coordinateSystem; - if (coordSys) { - // percentage is not allowed when coordinate system is specified - const point = coordSys.dataToPoint(center); - cx = point[0] || 0; - cy = point[1] || 0; - } - else { - if (!zrUtil.isArray(center)) { - center = [center, center]; - } - cx = parsePercent(center[0], width) + viewRect.x; - cy = parsePercent(center[1], height) + viewRect.y; - } - - return { - cx, - cy, - r0, - r - }; -} export default function pieLayout( seriesType: 'pie', @@ -89,9 +39,12 @@ export default function pieLayout( ecModel.eachSeriesByType(seriesType, function (seriesModel: PieSeriesModel) { const data = seriesModel.getData(); const valueDim = data.mapDimension('value'); - const viewRect = getViewRect(seriesModel, api); + const viewRect = layout.getViewRect(seriesModel, api); - const { cx, cy, r, r0 } = getBasicPieLayout(seriesModel, api); + const { cx, cy, r, r0 } = layout.getCircleLayout( + seriesModel as unknown as SeriesModel>, + api + ); let startAngle = -seriesModel.get('startAngle') * RADIAN; let endAngle = seriesModel.get('endAngle'); diff --git a/src/chart/sankey/sankeyLayout.ts b/src/chart/sankey/sankeyLayout.ts index 0ae24df1c5..410ca7dfbc 100644 --- a/src/chart/sankey/sankeyLayout.ts +++ b/src/chart/sankey/sankeyLayout.ts @@ -33,7 +33,7 @@ export default function sankeyLayout(ecModel: GlobalModel, api: ExtensionAPI) { const nodeWidth = seriesModel.get('nodeWidth'); const nodeGap = seriesModel.get('nodeGap'); - const layoutInfo = getViewRect(seriesModel, api); + const layoutInfo = layout.getViewRect(seriesModel, api); seriesModel.layoutInfo = layoutInfo; @@ -61,18 +61,6 @@ export default function sankeyLayout(ecModel: GlobalModel, api: ExtensionAPI) { }); } -/** - * Get the layout position of the whole view - */ -function getViewRect(seriesModel: SankeySeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - function layoutSankey( nodes: GraphNode[], edges: GraphEdge[], diff --git a/src/chart/tree/layoutHelper.ts b/src/chart/tree/layoutHelper.ts index 2aada604ec..04eac1272e 100644 --- a/src/chart/tree/layoutHelper.ts +++ b/src/chart/tree/layoutHelper.ts @@ -33,10 +33,7 @@ * the tree. */ -import * as layout from '../../util/layout'; import { TreeNode } from '../../data/Tree'; -import TreeSeriesModel from './TreeSeries'; -import ExtensionAPI from '../../core/ExtensionAPI'; interface HierNode { defaultAncestor: TreeLayoutNode, @@ -164,18 +161,6 @@ export function radialCoordinate(rad: number, r: number) { }; } -/** - * Get the layout position of the whole view. - */ -export function getViewRect(seriesModel: TreeSeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - /** * All other shifts, applied to the smaller subtrees between w- and w+, are * performed by this function. diff --git a/src/chart/tree/treeLayout.ts b/src/chart/tree/treeLayout.ts index d614347e2f..0f1fe87df5 100644 --- a/src/chart/tree/treeLayout.ts +++ b/src/chart/tree/treeLayout.ts @@ -27,12 +27,12 @@ import { secondWalk, separation as sep, radialCoordinate, - getViewRect, TreeLayoutNode } from './layoutHelper'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import TreeSeriesModel from './TreeSeries'; +import { getViewRect } from '../../util/layout'; export default function treeLayout(ecModel: GlobalModel, api: ExtensionAPI) { ecModel.eachSeriesByType('tree', function (seriesModel: TreeSeriesModel) { diff --git a/src/echarts.all.ts b/src/echarts.all.ts index 5eba7d34c7..f531897478 100644 --- a/src/echarts.all.ts +++ b/src/echarts.all.ts @@ -43,6 +43,7 @@ import { TreeChart, TreemapChart, GraphChart, + ChordChart, GaugeChart, FunnelChart, ParallelChart, @@ -127,6 +128,7 @@ use([ TreeChart, TreemapChart, GraphChart, + ChordChart, GaugeChart, FunnelChart, ParallelChart, diff --git a/src/export/charts.ts b/src/export/charts.ts index 4356de9147..3ccb850733 100644 --- a/src/export/charts.ts +++ b/src/export/charts.ts @@ -34,6 +34,7 @@ export {install as MapChart} from '../chart/map/install'; export {install as TreeChart} from '../chart/tree/install'; export {install as TreemapChart} from '../chart/treemap/install'; export {install as GraphChart} from '../chart/graph/install'; +export {install as ChordChart} from '../chart/chord/install'; export {install as GaugeChart} from '../chart/gauge/install'; export {install as FunnelChart} from '../chart/funnel/install'; export {install as ParallelChart} from '../chart/parallel/install'; @@ -83,6 +84,7 @@ export { TreeSeriesOption, TreemapSeriesOption, GraphSeriesOption, + ChordSeriesOption, GaugeSeriesOption, FunnelSeriesOption, ParallelSeriesOption, diff --git a/src/export/option.ts b/src/export/option.ts index d25e8197a3..a39fe92e6e 100644 --- a/src/export/option.ts +++ b/src/export/option.ts @@ -72,6 +72,7 @@ import type {MapSeriesOption as MapSeriesOptionInner} from '../chart/map/MapSeri import type {TreeSeriesOption as TreeSeriesOptionInner} from '../chart/tree/TreeSeries'; import type {TreemapSeriesOption as TreemapSeriesOptionInner} from '../chart/treemap/TreemapSeries'; import type {GraphSeriesOption as GraphSeriesOptionInner} from '../chart/graph/GraphSeries'; +import type {ChordSeriesOption as ChordSeriesOptionInner} from '../chart/chord/ChordSeries'; import type {GaugeSeriesOption as GaugeSeriesOptionInner} from '../chart/gauge/GaugeSeries'; import type {FunnelSeriesOption as FunnelSeriesOptionInner} from '../chart/funnel/FunnelSeries'; import type {ParallelSeriesOption as ParallelSeriesOptionInner} from '../chart/parallel/ParallelSeries'; @@ -187,6 +188,7 @@ export type MapSeriesOption = MapSeriesOptionInner & SeriesInjectedOption; export type TreeSeriesOption = TreeSeriesOptionInner & SeriesInjectedOption; export type TreemapSeriesOption = TreemapSeriesOptionInner & SeriesInjectedOption; export type GraphSeriesOption = GraphSeriesOptionInner & SeriesInjectedOption; +export type ChordSeriesOption = ChordSeriesOptionInner & SeriesInjectedOption; export type GaugeSeriesOption = GaugeSeriesOptionInner & SeriesInjectedOption; export type FunnelSeriesOption = FunnelSeriesOptionInner & SeriesInjectedOption; export type ParallelSeriesOption = ParallelSeriesOptionInner & SeriesInjectedOption; @@ -225,6 +227,7 @@ export interface RegisteredSeriesOption { tree: TreeSeriesOption treemap: TreemapSeriesOption graph: GraphSeriesOption + chord: ChordSeriesOption gauge: GaugeSeriesOption funnel: FunnelSeriesOption parallel: ParallelSeriesOption diff --git a/src/model/Global.ts b/src/model/Global.ts index 86c56f2e2b..add0aac637 100644 --- a/src/model/Global.ts +++ b/src/model/Global.ts @@ -120,6 +120,7 @@ const BUILTIN_CHARTS_MAP = { tree: 'TreeChart', treemap: 'TreemapChart', graph: 'GraphChart', + chord: 'ChordChart', gauge: 'GaugeChart', funnel: 'FunnelChart', parallel: 'ParallelChart', diff --git a/src/util/layout.ts b/src/util/layout.ts index 4138f74eec..dcf2f77626 100644 --- a/src/util/layout.ts +++ b/src/util/layout.ts @@ -23,10 +23,13 @@ import * as zrUtil from 'zrender/src/core/util'; import BoundingRect, { RectLike } from 'zrender/src/core/BoundingRect'; import {parsePercent} from './number'; import * as formatUtil from './format'; -import { BoxLayoutOptionMixin, ComponentLayoutMode } from './types'; +import { BoxLayoutOptionMixin, CircleLayoutOptionMixin, ComponentLayoutMode, SeriesOption } from './types'; import Group from 'zrender/src/graphic/Group'; +import { SectorShape } from 'zrender/src/graphic/shape/Sector'; import Element from 'zrender/src/Element'; import { Dictionary } from 'zrender/src/core/types'; +import SeriesModel from '../model/Series'; +import ExtensionAPI from '../core/ExtensionAPI'; const each = zrUtil.each; @@ -189,6 +192,60 @@ export function getAvailableSize( }; } +export function getViewRect(seriesModel: SeriesModel, api: ExtensionAPI) { + return getLayoutRect( + seriesModel.getBoxLayoutParams(), { + width: api.getWidth(), + height: api.getHeight() + } + ); +} + +export function getCircleLayout( + seriesModel: SeriesModel, + api: ExtensionAPI +): + Pick { + const viewRect = getViewRect(seriesModel, api); + + // center can be string or number when coordinateSystem is specified + let center = seriesModel.get('center'); + let radius = seriesModel.get('radius'); + + if (!zrUtil.isArray(radius)) { + radius = [0, radius]; + } + const width = parsePercent(viewRect.width, api.getWidth()); + const height = parsePercent(viewRect.height, api.getHeight()); + const size = Math.min(width, height); + const r0 = parsePercent(radius[0], size / 2); + const r = parsePercent(radius[1], size / 2); + + let cx: number; + let cy: number; + const coordSys = seriesModel.coordinateSystem; + if (coordSys) { + // percentage is not allowed when coordinate system is specified + const point = coordSys.dataToPoint(center); + cx = point[0] || 0; + cy = point[1] || 0; + } + else { + if (!zrUtil.isArray(center)) { + center = [center, center]; + } + cx = parsePercent(center[0], width) + viewRect.x; + cy = parsePercent(center[1], height) + viewRect.y; + } + + return { + cx, + cy, + r0, + r + }; +} + /** * Parse position info. */ diff --git a/test/chord.html b/test/chord.html new file mode 100644 index 0000000000..2d7b6ceee5 --- /dev/null +++ b/test/chord.html @@ -0,0 +1,719 @@ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/runTest/actions/__meta__.json b/test/runTest/actions/__meta__.json index 3a9a4d28cd..33ac96549c 100644 --- a/test/runTest/actions/__meta__.json +++ b/test/runTest/actions/__meta__.json @@ -50,6 +50,7 @@ "candlestick-large": 4, "candlestick-large2": 1, "candlestickConnect": 4, + "chord": 2, "clip": 12, "clip2": 2, "coarse-pointer": 3, diff --git a/test/runTest/actions/chord.json b/test/runTest/actions/chord.json new file mode 100644 index 0000000000..994dcb2dd3 --- /dev/null +++ b/test/runTest/actions/chord.json @@ -0,0 +1 @@ +[{"name":"Action 1","ops":[{"type":"mousemove","time":265,"x":150,"y":308},{"type":"mousemove","time":467,"x":83,"y":253},{"type":"mousemove","time":673,"x":61,"y":216},{"type":"mousemove","time":875,"x":51,"y":187},{"type":"mousemove","time":1084,"x":62,"y":187},{"type":"mousemove","time":1257,"x":62,"y":187},{"type":"mousemove","time":1459,"x":52,"y":177},{"type":"mousedown","time":1476,"x":52,"y":177},{"type":"mouseup","time":1581,"x":52,"y":177},{"time":1582,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2340,"x":52,"y":177},{"type":"mousedown","time":2360,"x":52,"y":177},{"type":"mouseup","time":2504,"x":52,"y":177},{"time":2505,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2782,"x":52,"y":177},{"type":"mousemove","time":2982,"x":150,"y":268},{"type":"mousemove","time":3182,"x":248,"y":341},{"type":"mousemove","time":3382,"x":280,"y":363},{"type":"mousemove","time":3661,"x":280,"y":363},{"type":"mousemove","time":3869,"x":261,"y":358},{"type":"mousemove","time":4069,"x":177,"y":333},{"type":"mousemove","time":4269,"x":173,"y":330},{"type":"mousemove","time":4473,"x":171,"y":329},{"type":"mousemove","time":4677,"x":274,"y":264},{"type":"mousemove","time":4878,"x":324,"y":248},{"type":"mousemove","time":5084,"x":343,"y":258},{"type":"mousemove","time":5342,"x":343,"y":258},{"type":"mousemove","time":5648,"x":343,"y":259},{"type":"mousemove","time":5848,"x":369,"y":293},{"type":"mousemove","time":6048,"x":370,"y":294},{"type":"mousemove","time":6107,"x":370,"y":293},{"type":"mousemove","time":6307,"x":397,"y":285},{"type":"mousemove","time":6509,"x":412,"y":284},{"type":"mousemove","time":7015,"x":413,"y":284},{"type":"mousemove","time":7215,"x":449,"y":290},{"type":"mousemove","time":7415,"x":465,"y":295},{"type":"mousemove","time":7617,"x":465,"y":296},{"type":"mousemove","time":7832,"x":466,"y":295},{"type":"mousemove","time":8032,"x":517,"y":278},{"type":"mousemove","time":8234,"x":560,"y":257},{"type":"mousemove","time":8440,"x":560,"y":256},{"type":"mousemove","time":8642,"x":560,"y":256}],"scrollY":4407.5,"scrollX":0,"timestamp":1740040500893},{"name":"Action 2","ops":[{"type":"mousemove","time":582,"x":288,"y":265},{"type":"mousemove","time":783,"x":271,"y":270},{"type":"mousemove","time":984,"x":267,"y":271},{"type":"mousemove","time":1191,"x":228,"y":291},{"type":"mousemove","time":1394,"x":205,"y":322},{"type":"mousemove","time":1609,"x":205,"y":322},{"type":"mousemove","time":1674,"x":205,"y":322},{"type":"mousemove","time":1875,"x":237,"y":377},{"type":"mousemove","time":2077,"x":241,"y":383},{"type":"mousemove","time":2232,"x":241,"y":383},{"type":"mousemove","time":2432,"x":250,"y":406},{"type":"mousemove","time":2635,"x":251,"y":411},{"type":"mousemove","time":3224,"x":251,"y":411},{"type":"mousemove","time":3424,"x":289,"y":413},{"type":"mousemove","time":3624,"x":319,"y":405},{"type":"mousemove","time":3826,"x":319,"y":404},{"type":"mousemove","time":3957,"x":319,"y":404},{"type":"mousemove","time":4157,"x":324,"y":423},{"type":"mousemove","time":4359,"x":325,"y":426},{"type":"mousemove","time":4566,"x":325,"y":426},{"type":"mousemove","time":4773,"x":329,"y":438},{"type":"mousemove","time":4976,"x":329,"y":438},{"type":"mousemove","time":5057,"x":329,"y":439},{"type":"mousemove","time":5257,"x":346,"y":451},{"type":"mousemove","time":5460,"x":347,"y":451},{"type":"mousemove","time":5574,"x":347,"y":452},{"type":"mousemove","time":5774,"x":354,"y":535},{"type":"mousemove","time":5974,"x":350,"y":556},{"type":"mousemove","time":6183,"x":352,"y":556},{"type":"mousemove","time":6383,"x":533,"y":509},{"type":"mousemove","time":6583,"x":576,"y":476},{"type":"mousemove","time":6785,"x":579,"y":476},{"type":"mousemove","time":6991,"x":594,"y":470},{"type":"mousemove","time":7193,"x":643,"y":470},{"type":"mousemove","time":7401,"x":657,"y":475},{"type":"mousemove","time":7607,"x":680,"y":452},{"type":"mousemove","time":7807,"x":712,"y":414},{"type":"mousemove","time":8010,"x":719,"y":409},{"type":"mousemove","time":8216,"x":720,"y":409},{"type":"mousemove","time":8416,"x":743,"y":414},{"type":"mousemove","time":8618,"x":744,"y":415},{"type":"mousemove","time":9032,"x":744,"y":415},{"type":"mousemove","time":9232,"x":744,"y":433},{"type":"mousemove","time":9432,"x":745,"y":450},{"type":"mousemove","time":9632,"x":754,"y":461},{"type":"mousemove","time":9832,"x":731,"y":487},{"type":"mousemove","time":10033,"x":701,"y":489},{"type":"mousemove","time":10235,"x":698,"y":487},{"type":"mousemove","time":10666,"x":698,"y":487},{"type":"mousemove","time":10866,"x":766,"y":491},{"type":"mousemove","time":11068,"x":776,"y":492},{"type":"mousedown","time":12728,"x":776,"y":492},{"type":"mouseup","time":12861,"x":776,"y":492},{"time":12862,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13982,"x":776,"y":491},{"type":"mousemove","time":14184,"x":776,"y":490}],"scrollY":4859.5,"scrollX":0,"timestamp":1740040547284}] \ No newline at end of file diff --git a/test/runTest/client/client.js b/test/runTest/client/client.js index ba8d811177..e4a6951352 100644 --- a/test/runTest/client/client.js +++ b/test/runTest/client/client.js @@ -157,9 +157,9 @@ const app = new Vue({ runConfig: Object.assign({ sortBy: 'name', actualVersion: 'local', + actualSource: 'local', expectedVersion: null, expectedSource: 'release', - actualSource: 'release', renderer: 'canvas', useCoarsePointer: 'auto', threads: 4