feat(plugin-chart-graph): add node/edge size and edge symbol control (#1084)

* feat(plugin-chart-graph): add node/edge size and edge symbol control

* Fix test case
This commit is contained in:
Jesse Yang 2021-04-30 17:15:18 -07:00 committed by Yongjie Zhao
parent 6089bcfd89
commit 85319109a5
5 changed files with 167 additions and 49 deletions

View File

@ -25,8 +25,6 @@ export const DEFAULT_GRAPH_SERIES_OPTION: GraphSeriesOption = {
initLayout: 'circular',
layoutAnimation: true,
},
edgeSymbol: ['circle', 'arrow'],
edgeSymbolSize: [10, 10],
label: {
show: true,
position: 'right',
@ -43,16 +41,13 @@ export const DEFAULT_GRAPH_SERIES_OPTION: GraphSeriesOption = {
},
emphasis: {
focus: 'adjacency',
lineStyle: {
width: 10,
},
},
animation: true,
animationDuration: 500,
animationEasing: 'cubicOut',
lineStyle: { color: 'source', curveness: 0.1 },
select: {
itemStyle: { borderWidth: 3 },
itemStyle: { borderWidth: 3, opacity: 1 },
label: { fontWeight: 'bolder' },
},
// Ref: https://echarts.apache.org/en/option.html#series-graph.data.tooltip.formatter
@ -60,10 +55,3 @@ export const DEFAULT_GRAPH_SERIES_OPTION: GraphSeriesOption = {
// - c: data value
tooltip: { formatter: '{b}: {c}' },
};
export const NORMALIZATION_LIMITS = {
minNodeSize: 10,
maxNodeSize: 60,
minEdgeWidth: 0.5,
maxEdgeWidth: 8,
};

View File

@ -121,6 +121,24 @@ const controlPanel: ControlPanelConfig = {
},
},
],
[
{
name: 'edgeSymbol',
config: {
type: 'SelectControl',
renderTrigger: true,
label: t('Edge symbols'),
description: t('Symbol of two ends of edge line'),
default: DEFAULT_FORM_DATA.edgeSymbol,
choices: [
['none,none', t('None -> None')],
['none,arrow', t('None -> Arrow')],
['circle,arrow', t('Circle -> Arrow')],
['circle,circle', t('Circle -> Circle')],
],
},
},
],
[
{
name: 'draggable',
@ -184,6 +202,34 @@ const controlPanel: ControlPanelConfig = {
},
},
],
[
{
name: 'baseNodeSize',
config: {
type: 'TextControl',
label: t('Node size'),
renderTrigger: true,
isFloat: true,
default: DEFAULT_FORM_DATA.baseNodeSize,
description: t(
'Median node size, the largest node will be 4 times larger than the smallest',
),
},
},
{
name: 'baseEdgeWidth',
config: {
type: 'TextControl',
label: t('Edge width'),
renderTrigger: true,
isFloat: true,
default: DEFAULT_FORM_DATA.baseEdgeWidth,
description: t(
'Median edge width, the thickest edge will be 4 times thicker than the thinnest.',
),
},
},
],
[
{
name: 'edgeLength',

View File

@ -30,43 +30,88 @@ import {
EchartsGraphFormData,
EChartGraphNode,
DEFAULT_FORM_DATA as DEFAULT_GRAPH_FORM_DATA,
EdgeSymbol,
} from './types';
import { DEFAULT_GRAPH_SERIES_OPTION, NORMALIZATION_LIMITS } from './constants';
import { DEFAULT_GRAPH_SERIES_OPTION } from './constants';
import { EchartsProps } from '../types';
import { getChartPadding, getLegendProps } from '../utils/series';
type EdgeWithStyles = GraphEdgeItemOption & {
lineStyle: Exclude<GraphEdgeItemOption['lineStyle'], undefined>;
emphasis: Exclude<GraphEdgeItemOption['emphasis'], undefined>;
select: Exclude<GraphEdgeItemOption['select'], undefined>;
};
function verifyEdgeSymbol(symbol: string): EdgeSymbol {
if (symbol === 'none' || symbol === 'circle' || symbol === 'arrow') {
return symbol;
}
return 'none';
}
function parseEdgeSymbol(symbols?: string | null): [EdgeSymbol, EdgeSymbol] {
const [start, end] = (symbols || '').split(',');
return [verifyEdgeSymbol(start), verifyEdgeSymbol(end)];
}
/**
* Emphasized edge width with a min and max.
*/
function getEmphasizedEdgeWidth(width: number) {
return Math.max(5, Math.min(width * 2, 20));
}
/**
* Normalize node size, edge width, and apply label visibility thresholds.
*/
function normalizeStyles(
nodes: EChartGraphNode[],
links: GraphEdgeItemOption[],
links: EdgeWithStyles[],
{
baseNodeSize,
baseEdgeWidth,
showSymbolThreshold,
}: {
baseNodeSize: number;
baseEdgeWidth: number;
showSymbolThreshold?: number;
},
) {
const minNodeSize = baseNodeSize * 0.5;
const maxNodeSize = baseNodeSize * 2;
const minEdgeWidth = baseEdgeWidth * 0.5;
const maxEdgeWidth = baseEdgeWidth * 2;
const [nodeMinValue, nodeMaxValue] = d3Extent(nodes, x => x.value) as [number, number];
const nodeSpread = nodeMaxValue - nodeMinValue;
nodes.forEach(node => {
// eslint-disable-next-line no-param-reassign
node.symbolSize =
(((node.value - nodeMinValue) / nodeSpread) * NORMALIZATION_LIMITS.maxNodeSize || 0) +
NORMALIZATION_LIMITS.minNodeSize;
node.symbolSize = (((node.value - nodeMinValue) / nodeSpread) * maxNodeSize || 0) + minNodeSize;
// eslint-disable-next-line no-param-reassign
node.label = {
...node.label,
show: showSymbolThreshold ? node.value > showSymbolThreshold : true,
};
});
const [linkMinValue, linkMaxValue] = d3Extent(links, x => x.value) as [number, number];
const linkSpread = linkMaxValue - linkMinValue;
links.forEach(link => {
const lineWidth =
((link.value! - linkMinValue) / linkSpread) * maxEdgeWidth || 0 + minEdgeWidth;
// eslint-disable-next-line no-param-reassign
link.lineStyle!.width =
((link.value! - linkMinValue) / linkSpread) * NORMALIZATION_LIMITS.maxEdgeWidth ||
0 + NORMALIZATION_LIMITS.minEdgeWidth;
link.lineStyle.width = lineWidth;
// eslint-disable-next-line no-param-reassign
link.emphasis.lineStyle = {
...link.emphasis.lineStyle,
width: getEmphasizedEdgeWidth(lineWidth),
};
// eslint-disable-next-line no-param-reassign
link.select.lineStyle = {
...link.select.lineStyle,
width: getEmphasizedEdgeWidth(lineWidth * 0.8),
opacity: 1,
};
});
}
@ -122,6 +167,9 @@ export default function transformProps(chartProps: ChartProps): EchartsProps {
legendOrientation,
legendType,
showLegend,
baseEdgeWidth,
baseNodeSize,
edgeSymbol,
}: EchartsGraphFormData = { ...DEFAULT_GRAPH_FORM_DATA, ...formData };
const metricLabel = getMetricLabel(metric);
@ -129,7 +177,7 @@ export default function transformProps(chartProps: ChartProps): EchartsProps {
const nodes: { [name: string]: number } = {};
const categories: Set<string> = new Set();
const echartNodes: EChartGraphNode[] = [];
const echartLinks: GraphEdgeItemOption[] = [];
const echartLinks: EdgeWithStyles[] = [];
/**
* Get the node id of an existing node,
@ -183,10 +231,12 @@ export default function transformProps(chartProps: ChartProps): EchartsProps {
target: targetNode.id,
value,
lineStyle: {},
emphasis: {},
select: {},
});
});
normalizeStyles(echartNodes, echartLinks, { showSymbolThreshold });
normalizeStyles(echartNodes, echartLinks, { showSymbolThreshold, baseEdgeWidth, baseNodeSize });
const categoryList = [...categories];
@ -202,8 +252,8 @@ export default function transformProps(chartProps: ChartProps): EchartsProps {
links: echartLinks,
roam,
draggable,
edgeSymbol: DEFAULT_GRAPH_SERIES_OPTION.edgeSymbol,
edgeSymbolSize: DEFAULT_GRAPH_SERIES_OPTION.edgeSymbolSize,
edgeSymbol: parseEdgeSymbol(edgeSymbol),
edgeSymbolSize: baseEdgeWidth * 2,
selectedMode,
...getChartPadding(showLegend, legendOrientation, legendMargin),
animation: DEFAULT_GRAPH_SERIES_OPTION.animation,

View File

@ -25,6 +25,8 @@ import {
LegendType,
} from '../types';
export type EdgeSymbol = 'none' | 'circle' | 'arrow';
export type EchartsGraphFormData = EchartsLegendFormData & {
source: string;
target: string;
@ -39,7 +41,10 @@ export type EchartsGraphFormData = EchartsLegendFormData & {
showSymbolThreshold: number;
repulsion: number;
gravity: number;
baseNodeSize: number;
baseEdgeWidth: number;
edgeLength: number;
edgeSymbol: string;
friction: number;
};
@ -59,7 +64,10 @@ export const DEFAULT_FORM_DATA: EchartsGraphFormData = {
showSymbolThreshold: 0,
repulsion: 1000,
gravity: 0.3,
edgeSymbol: 'none,arrow',
edgeLength: 400,
baseEdgeWidth: 3,
baseNodeSize: 20,
friction: 0.2,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Scroll,

View File

@ -68,51 +68,77 @@ describe('EchartsGraph tranformProps', () => {
expect.objectContaining({
data: [
{
category: undefined,
id: '0',
label: { show: true },
name: 'source_value_1',
select: {
itemStyle: { borderWidth: 3, opacity: 1 },
label: { fontWeight: 'bolder' },
},
symbolSize: 50,
tooltip: { formatter: '{b}: {c}' },
value: 6,
symbolSize: 70,
category: undefined,
select: DEFAULT_GRAPH_SERIES_OPTION.select,
tooltip: DEFAULT_GRAPH_SERIES_OPTION.tooltip,
label: { show: true },
},
{
category: undefined,
id: '1',
label: { show: true },
name: 'target_value_1',
select: {
itemStyle: { borderWidth: 3, opacity: 1 },
label: { fontWeight: 'bolder' },
},
symbolSize: 50,
tooltip: { formatter: '{b}: {c}' },
value: 6,
symbolSize: 70,
category: undefined,
select: DEFAULT_GRAPH_SERIES_OPTION.select,
tooltip: DEFAULT_GRAPH_SERIES_OPTION.tooltip,
label: { show: true },
},
{
category: undefined,
id: '2',
name: 'source_value_2',
value: 5,
symbolSize: 10,
category: undefined,
select: DEFAULT_GRAPH_SERIES_OPTION.select,
tooltip: DEFAULT_GRAPH_SERIES_OPTION.tooltip,
label: { show: true },
name: 'source_value_2',
select: {
itemStyle: { borderWidth: 3, opacity: 1 },
label: { fontWeight: 'bolder' },
},
symbolSize: 10,
tooltip: { formatter: '{b}: {c}' },
value: 5,
},
{
id: '3',
name: 'target_value_2',
value: 5,
symbolSize: 10,
category: undefined,
select: DEFAULT_GRAPH_SERIES_OPTION.select,
tooltip: DEFAULT_GRAPH_SERIES_OPTION.tooltip,
id: '3',
label: { show: true },
name: 'target_value_2',
select: {
itemStyle: { borderWidth: 3, opacity: 1 },
label: { fontWeight: 'bolder' },
},
symbolSize: 10,
tooltip: { formatter: '{b}: {c}' },
value: 5,
},
],
}),
expect.objectContaining({
links: [
{ source: '0', target: '1', value: 6, lineStyle: { width: 8 } },
{ source: '2', target: '3', value: 5, lineStyle: { width: 0.5 } },
{
emphasis: { lineStyle: { width: 12 } },
lineStyle: { width: 6 },
select: { lineStyle: { opacity: 1, width: 9.600000000000001 } },
source: '0',
target: '1',
value: 6,
},
{
emphasis: { lineStyle: { width: 5 } },
lineStyle: { width: 1.5 },
select: { lineStyle: { opacity: 1, width: 5 } },
source: '2',
target: '3',
value: 5,
},
],
}),
]),