Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Chord charts #20522

Open
wants to merge 31 commits into
base: v6
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3027379
refactor: remove duplicated logic
Ovilia Nov 18, 2024
c3dda28
WIP(chord): init chord
Ovilia Nov 18, 2024
4ec469b
WIP(chord): render chord ring
Ovilia Nov 19, 2024
d6f751d
WIP(chord): render chord edges
Ovilia Nov 20, 2024
61eddf4
WIP(chord): render chord edges
Ovilia Nov 20, 2024
b48637d
WIP(chord): padAngle
Ovilia Nov 20, 2024
9d70343
WIP(chord): minAngle
Ovilia Nov 21, 2024
ce5b912
WIP(chord): gradient color
Ovilia Nov 22, 2024
7b61189
WIP(chord): edge tooltip
Ovilia Nov 25, 2024
8f8edea
WIP(chord): link data with legend
Ovilia Dec 16, 2024
668aa13
WIP(chord): animation
Ovilia Dec 17, 2024
01bea53
WIP(chord): animation
Ovilia Dec 18, 2024
39a2de6
WIP(chord): init animation
Ovilia Dec 19, 2024
2cc958a
WIP(chord): update test
Ovilia Dec 23, 2024
b8c1c25
WIP(chord): fix minAngle
Ovilia Dec 24, 2024
0bf56e2
WIP(chord): update test
Ovilia Dec 25, 2024
f9fe353
WIP(chord): more test cases
Ovilia Dec 26, 2024
03e2883
fix(chord): fix lint errors
Ovilia Feb 7, 2025
b4ca13f
feat(chord): support tooltip
Ovilia Feb 8, 2025
6724f9a
feat(chord): legend with items
Ovilia Feb 8, 2025
e8961ee
feat(chord): node value can be larger than edge sum
Ovilia Feb 10, 2025
fd0bdbd
feat(chord): support links.value being all 0
Ovilia Feb 10, 2025
869eb99
feat(chord): WIP clockwise
Ovilia Feb 12, 2025
824791f
fix(chord): fix clockwise and some failed cases
Ovilia Feb 13, 2025
c4df9c0
feat(chord): support label
Ovilia Feb 14, 2025
969bc1c
feat(chord): WIP focus
Ovilia Feb 17, 2025
cf7440d
feat(chord): focus supported
Ovilia Feb 18, 2025
4ca9d39
style(chord): remove unused imports
Ovilia Feb 18, 2025
db1896e
Merge branch 'v6' into feat-chord
Ovilia Feb 20, 2025
8e258fa
test: fix test visual
Ovilia Feb 20, 2025
bfed24a
test(chord): add test visual actions
Ovilia Feb 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions src/chart/chord/ChordEdge.ts
Original file line number Diff line number Diff line change
@@ -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<ChordPathShape>
}

export class ChordEdge extends graphic.Path<ChordEdgePathProps> {
shape: ChordPathShape;

constructor(
nodeData: SeriesData<ChordSeriesModel>,
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<ChordSeriesModel>,
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<ChordNodeItemOption>();
const edgeModel = edgeData.getItemModel<ChordEdgeItemOption>(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<ChordSeriesModel>,
lineStyleModel: Model<ChordEdgeLineStyleOption>
) {
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;
}
}
169 changes: 169 additions & 0 deletions src/chart/chord/ChordPiece.ts
Original file line number Diff line number Diff line change
@@ -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<ChordNodeItemOption>();
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<ChordNodeItemOption>,
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
}
});
}
}

Loading