@antv/g2
Version:
the Grammar of Graphics in Javascript
313 lines (280 loc) • 8.87 kB
text/typescript
import { deepMix, pick } from '@antv/util';
import { CompositeMarkComponent } from '../runtime';
import { BaseMark, ChannelTypes, PartitionNode } from '../spec';
export type PartitionMark = BaseMark<'rect', 'value' | ChannelTypes>;
export interface PartitionDataNode {
data: PartitionNode;
depth: number;
parent: PartitionDataNode | null;
children: PartitionDataNode[];
x0: number;
x1: number;
value: number;
}
export interface LayoutOptions {
valueField?: string;
sort?: (a: PartitionNode, b: PartitionNode) => number;
fillParent?: boolean; // Whether child nodes fill parent width.
nameField?: string;
}
/**
* Partition layout algorithm.
* Child nodes start layout from the parent's starting position to show parent-child relationships.
*
* @param data Hierarchical data
* @param options Configuration options
*/
export function partitionLayout(
data: PartitionNode[],
options: LayoutOptions = {},
) {
const {
valueField = 'value',
sort,
fillParent = true,
nameField = 'name',
} = options;
if (!data || data.length === 0) return [];
// Build hierarchical structure
const buildPartition = (
node: PartitionNode,
parent: PartitionDataNode | null = null,
depth = 0,
): PartitionDataNode => {
const partitionNode: PartitionDataNode = {
data: node,
depth,
parent,
children: [],
x0: 0,
x1: 0,
value: node[valueField] || 0,
};
if (node.children && node.children.length > 0) {
partitionNode.children = node.children.map((child: PartitionNode) =>
buildPartition(child, partitionNode, depth + 1),
);
}
return partitionNode;
};
// Process each root node
const result: Array<Record<string, any>> = [];
let currentRootStartX = 0; // Track the starting position for the next root node
data.forEach((rootData: PartitionNode) => {
const root = buildPartition(rootData);
// Calculate position for each node - key point: child nodes start layout from parent's starting position
const calculateLayout = (
node: PartitionDataNode,
parentStartX = 0,
isRootNode = false,
parentWidth = 0, // Parent node actual width.
): void => {
if (isRootNode || node.depth === 0) {
// Root node: start from current root position
node.x0 = isRootNode ? parentStartX : 0;
node.x1 = node.x0 + node.value;
} else {
// Child node: start layout from parent's starting position
node.x0 = parentStartX;
if (fillParent && parentWidth > 0) {
// If fillParent is true, calculate width based on parent width and value ratio.
const siblingsTotalValue = node.parent
? node.parent.children.reduce((acc, child) => acc + child.value, 0)
: node.value;
const siblingsCount = node.parent ? node.parent.children.length : 1;
const ratio =
siblingsTotalValue > 0
? node.value / siblingsTotalValue
: 1 / siblingsCount;
node.x1 = parentStartX + parentWidth * ratio;
} else {
// If fillParent is false, use node own value as width.
node.x1 = parentStartX + node.value;
}
}
// Calculate position for child nodes - start from current node's starting position
let childStartX = node.x0;
const nodeWidth = node.x1 - node.x0;
const sortedChildren = sort
? [...node.children].sort(
(a: PartitionDataNode, b: PartitionDataNode) =>
sort(a.data, b.data),
)
: node.children;
if (fillParent && sortedChildren.length > 0) {
// fillParent mode: child nodes fill parent width proportionally.
const childrenTotalValue = node.children.reduce(
(sum, c) => sum + c.value,
0,
);
sortedChildren.forEach((child: PartitionDataNode) => {
calculateLayout(child, childStartX, false, nodeWidth);
const ratio =
childrenTotalValue > 0
? child.value / childrenTotalValue
: 1 / sortedChildren.length;
childStartX += nodeWidth * ratio;
});
} else {
// Non-fillParent mode: child nodes layout independently based on own value.
sortedChildren.forEach((child: PartitionDataNode) => {
calculateLayout(child, childStartX, false, 0);
// Next child node starts from current child node's end position.
childStartX += child.x1 - child.x0;
});
}
};
// Start layout calculation from root node, using current root start position.
calculateLayout(root, currentRootStartX, true);
// Update the starting position for the next root node.
currentRootStartX += root.value;
// Convert to final format.
const processNode = (node: PartitionDataNode): Record<string, any> => {
const getName = (d: PartitionNode) => d[nameField] ?? d.name;
const path = [getName(node.data)];
let ancestorNode = node;
while (ancestorNode.parent) {
path.unshift(getName(ancestorNode.parent.data));
ancestorNode = ancestorNode.parent;
}
return {
...pick(node.data, [valueField]),
[PARTITION_PATH_FIELD]: path,
[PARTITION_ANCESTOR_FIELD]:
ancestorNode.parent?.data?.[nameField] ?? node.data[nameField],
name: node.data[nameField],
depth: node.depth,
value: node.value,
x: [node.x0, node.x1],
y: [node.depth, node.depth + 1],
// Add child node count attribute for drill-down interaction judgment.
[CHILD_NODE_COUNT]: node.children.length,
};
};
// Collect all nodes.
const collectResultNodes = (node: PartitionDataNode): void => {
result.push(processNode(node));
node.children.forEach(collectResultNodes);
};
collectResultNodes(root);
});
return result;
}
export type PartitionData = PartitionNode[];
export type PartitionOptions = Omit<PartitionMark, 'type'> & {
fillParent?: boolean; // Whether child nodes fill parent width.
};
export const PARTITION_TYPE = 'partition';
export const PARTITION_TYPE_FIELD = 'markType';
export const PARTITION_PATH_FIELD = 'path';
export const PARTITION_ANCESTOR_FIELD = 'ancestor-node';
export const CHILD_NODE_COUNT = 'childNodeCount';
export function transformData(
options: Pick<PartitionOptions, 'data' | 'encode'> & {
fillParent?: boolean;
sort?: (a: PartitionNode, b: PartitionNode) => number;
},
) {
const { data, encode, fillParent, sort } = options;
const { color, value, name } = encode as any;
const nodes = partitionLayout(data, {
valueField: value,
fillParent,
nameField: name,
sort,
});
return nodes.map((node: Record<string, any>) => {
// Handle color mapping.
const nodeInfo = { ...node };
if (color && color !== PARTITION_ANCESTOR_FIELD) {
nodeInfo[color] = node[color];
}
return nodeInfo;
});
}
const DEFAULT_OPTIONS = {
id: PARTITION_TYPE,
encode: {
x: 'x',
y: 'y',
key: PARTITION_PATH_FIELD,
color: PARTITION_ANCESTOR_FIELD,
value: 'value',
name: 'name',
},
labels: [
{
style: {
pointerEvents: 'none',
},
text: 'value',
position: 'inside',
transform: [
{
type: 'overflowHide',
},
],
},
],
axis: {
x: { title: 'Time/Order', label: true },
y: false,
},
style: {
[PARTITION_TYPE_FIELD]: PARTITION_TYPE,
[CHILD_NODE_COUNT]: 'childNodeCount', // Add child node count attribute for drill-down interaction.
},
state: {
active: { zIndex: 2 },
inactive: { zIndex: 1 },
},
legend: false,
coordinate: {
type: 'cartesian',
grid: false, // Remove grid lines.
},
interaction: {
drillDown: true,
},
};
export const Partition: CompositeMarkComponent<PartitionOptions> = (
options,
) => {
const {
encode: encodeOption,
data = [],
layout = {},
...resOptions
} = options;
const { fillParent = true, sort } = layout as {
fillParent?: boolean;
sort?: (a: PartitionNode, b: PartitionNode) => number;
};
const encode = { ...DEFAULT_OPTIONS.encode, ...encodeOption };
const { value } = encode;
const rectData = transformData({ encode, data, fillParent, sort });
return [
deepMix({}, DEFAULT_OPTIONS, {
type: 'rect',
data: rectData,
encode,
tooltip: {
title: 'path',
items: [
(d: Record<string, any>) => {
return {
name: value as string,
value: d[value],
};
},
],
},
// Add basic interaction.
interaction: {
elementHighlight: true,
},
...resOptions,
}),
];
};
Partition.props = {};