Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,8 @@ export const sankeySeriesThemeableOptionsDef: OptionsDefs<AgSankeySeriesThemeabl
width: positiveNumber,
spacing: positiveNumber,
alignment: union('left', 'center', 'right', 'justify'),
verticalAlignment: union('top', 'bottom', 'center'),
sort: union('data', 'a-z', 'z-a', 'weight'),
itemStyler: callbackDefs<AgSankeySeriesNodeStyle>({
...fillOptionsDef,
...strokeOptionsDef,
Expand Down
92 changes: 49 additions & 43 deletions packages/ag-charts-enterprise/src/series/sankey/sankeySeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export class SankeySeries extends FlowProportionSeries<
const size = link.link.size;
const ghostNode: GhostNodeGraphEntry = {
ghost: true,
datum: { size, y: 0, height: 0 },
datum: { ...graphNode.datum, size, y: 0, height: 0 },
weight: 0,
linksBefore: [{ node: { columnIndex: i - 1, datum: { size } } }],
linksAfter: [{ node: { columnIndex: i + 1, datum: { size } } }],
Expand Down Expand Up @@ -312,34 +312,19 @@ export class SankeySeries extends FlowProportionSeries<
}, 0);
}

column.nodes.sort((a: any, b: any) => {
// Ghost nodes reference their concrete column index so are sorted such that ghosts linked before are
// given priority
if (a.columnIndex < b.columnIndex) return -1;
if (a.columnIndex > b.columnIndex) return 1;

// Ghost nodes with the same weight are compared by their associated concrete datum's size
if (a.weight === b.weight) {
return a.datum.size - b.datum.size;
}

// Sort nodes that have distal links to the top
if (a.closestColumnDiff < b.closestColumnDiff) return 1;
if (a.closestColumnDiff > b.closestColumnDiff) return -1;

// Sort heavier nodes to the bottom
return a.weight - b.weight;
});
column.nodes.sort((a, b) => this.sortNodes(a as EnhancedNodeGraphEntry, b as EnhancedNodeGraphEntry));
}

// Layout the nodes in their columns
for (const column of columns) {
const nodesHeight = seriesRectHeight * column.size * sizeScale;
let y = (seriesRectHeight - (nodesHeight + nodeSpacing * (column.nodes.length - 1))) / 2; // center

// TODO: vertical alignment option
// let y = seriesRectHeight - (nodesHeight + nodeSpacing * (column.nodes.length - 1)); // bottom
// let y = 0; // top
let y = 0;
if (this.properties.node.verticalAlignment === 'bottom') {
y = seriesRectHeight - (nodesHeight + nodeSpacing * (column.nodes.length - 1));
} else if (this.properties.node.verticalAlignment === 'center') {
y = (seriesRectHeight - (nodesHeight + nodeSpacing * (column.nodes.length - 1))) / 2;
}

for (const node of column.nodes) {
const height = seriesRectHeight * node.datum.size * sizeScale;
Expand All @@ -361,30 +346,20 @@ export class SankeySeries extends FlowProportionSeries<
hasNegativeNodeHeight ||= datum.height < 0;

let y2 = datum.y;
linksBefore.sort((a, b) => {
const aNode = a.node as EnhancedNodeGraphEntry;
const bNode = b.node as EnhancedNodeGraphEntry;

if (aNode.columnIndex < bNode.columnIndex) return -1;
if (aNode.columnIndex > bNode.columnIndex) return 1;

return aNode.weight - bNode.weight;
});
linksBefore.sort((a, b) =>
this.sortNodes(a.node as EnhancedNodeGraphEntry, b.node as EnhancedNodeGraphEntry)
);
linksBefore.forEach(({ link }) => {
link.y2 = y2;
y2 += link.size * seriesRectHeight * sizeScale;
});

let y1 = datum.y;
linksAfter.sort((a, b) => {
const aNode = a.node as EnhancedNodeGraphEntry;
const bNode = b.node as EnhancedNodeGraphEntry;

if (aNode.columnIndex < bNode.columnIndex) return 1;
if (aNode.columnIndex > bNode.columnIndex) return -1;

return aNode.weight - bNode.weight;
});
linksAfter.sort((a, b) =>
this.sortNodes(a.node as EnhancedNodeGraphEntry, b.node as EnhancedNodeGraphEntry, {
invertColumnSort: true,
})
);
linksAfter.forEach(({ link }) => {
link.y1 = y1;
y1 += link.size * seriesRectHeight * sizeScale;
Expand All @@ -410,8 +385,10 @@ export class SankeySeries extends FlowProportionSeries<

let bottom = -Infinity;
column.nodes.sort((a, b) => a.datum.y - b.datum.y);
column.nodes.forEach(({ datum: node }) => {
if (!('label' in node)) return;
column.nodes.forEach((n) => {
if ('ghost' in n && n.ghost) return;

const { datum: node } = n as EnhancedNodeGraphEntry;

node.midPoint = {
x: node.x + node.width / 2,
Expand Down Expand Up @@ -488,6 +465,35 @@ export class SankeySeries extends FlowProportionSeries<
};
}

private sortNodes(a: EnhancedNodeGraphEntry, b: EnhancedNodeGraphEntry, opts?: { invertColumnSort: boolean }) {
const { properties } = this;

if (properties.node.sort === 'a-z') {
return (a.datum.label ?? '').localeCompare(b.datum.label ?? '');
} else if (properties.node.sort === 'z-a') {
return (b.datum.label ?? '').localeCompare(a.datum.label ?? '');
} else if (properties.node.sort === 'data') {
return 0;
}

// Ghost nodes reference their concrete column index so are sorted such that ghosts linked before are
// given priority
if (a.columnIndex < b.columnIndex) return opts?.invertColumnSort ? 1 : -1;
if (a.columnIndex > b.columnIndex) return opts?.invertColumnSort ? -1 : 1;

// Ghost nodes with the same weight are compared by their associated concrete datum's size
if (a.weight === b.weight) {
return a.datum.size - b.datum.size;
}

// Sort nodes that have distal links to the top
if (a.closestColumnDiff < b.closestColumnDiff) return 1;
if (a.closestColumnDiff > b.closestColumnDiff) return -1;

// Sort heavier nodes to the bottom
return a.weight - b.weight;
}

protected updateLabelSelection(opts: {
labelData: SankeyNodeLabelDatum[];
labelSelection: _ModuleSupport.Selection<_ModuleSupport.TransformableText, SankeyNodeLabelDatum>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ class SankeySeriesNodeProperties extends BaseProperties<AgSankeySeriesNodeOption
@Property
alignment: 'left' | 'right' | 'center' | 'justify' = 'justify';

@Property
verticalAlignment: 'top' | 'bottom' | 'center' = 'center';

@Property
sort: 'data' | 'a-z' | 'z-a' | 'weight' = 'weight';

@Property
fill: InternalAgColorType | undefined = undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,24 @@ export interface AgSankeySeriesNodeOptions<TDatum, TContext = ContextDefault> ex
spacing?: PixelSize;
/** Width of the nodes. */
width?: PixelSize;
/** Alignment of the nodes. */
/**
* Alignment of the nodes.
*
* Default: `'center'`
*/
alignment?: 'left' | 'right' | 'center' | 'justify';
/**
* Vertical alignment of the nodes.
*
* Default: `'center'`
*/
verticalAlignment?: 'top' | 'bottom' | 'center';
/**
* Sorting method of the nodes.
*
* Default: `'weight'`
*/
sort?: 'data' | 'a-z' | 'z-a' | 'weight';
/** Function used to return formatting for individual nodes, based on the given parameters. If the current node is highlighted, the `highlighted` property will be set to `true`; make sure to check this if you want to differentiate between the highlighted and un-highlighted states. */
itemStyler?: Styler<AgSankeySeriesNodeItemStylerParams<TDatum, TContext>, AgSankeySeriesNodeStyle>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Fictitious data used for demonstration purposes
import { AgCharts, AgFlowProportionChartOptions, AgSankeySeriesOptions } from 'ag-charts-enterprise';

const options: AgFlowProportionChartOptions = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div class="example-controls">
<div class="controls-row">
<button onclick="sortData()"><code>'data'</code></button>
<button onclick="sortAZ()"><code>'a-z'</code></button>
<button onclick="sortZA()"><code>'z-a'</code></button>
<button onclick="sortWeight()"><code>'weight'</code></button>
</div>
</div>
<div id="myChart"></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { AgCharts, AgFlowProportionChartOptions, AgSankeySeriesOptions } from 'ag-charts-enterprise';

const options: AgFlowProportionChartOptions = {
container: document.getElementById('myChart'),
title: {
text: 'Company Revenue',
},
subtitle: {
text: '2023',
},
data: [
{ from: 'Footwear', to: 'North America', size: 2245 },
{ from: 'Footwear', to: 'Europe, Middle East & Africa', size: 1419 },
{ from: 'Footwear', to: 'Asia Pacific & Latin America', size: 879 },
{ from: 'Footwear', to: 'Greater China', size: 1022 },
{ from: 'Apparel', to: 'North America', size: 1405 },
{ from: 'Apparel', to: 'Asia Pacific & Latin America', size: 360 },
{ from: 'Apparel', to: 'Europe, Middle East & Africa', size: 794 },
{ from: 'Apparel', to: 'Greater China', size: 490 },
{ from: 'Equipment', to: 'North America', size: 132 },
{ from: 'Equipment', to: 'Europe, Middle East & Africa', size: 100 },
{ from: 'Equipment', to: 'Asia Pacific & Latin America', size: 59 },
{ from: 'Equipment', to: 'Greater China', size: 32 },
{ from: 'North America', to: 'NIKE Brand', size: 3782 },
{ from: 'Europe, Middle East & Africa', to: 'NIKE Brand', size: 2313 },
{ from: 'Greater China', to: 'NIKE Brand', size: 1544 },
{ from: 'Asia Pacific & Latin America', to: 'NIKE Brand', size: 1298 },
{ from: 'Global Brand Divisions', to: 'NIKE Brand', size: 9 },
{ from: 'NIKE Brand', to: 'Revenues', size: 8946 },
{ from: 'Converse', to: 'Revenues', size: 425 },
{ from: 'Corporate', to: 'Revenues', size: 3 },
{ from: 'Revenues', to: 'Cost of sales', size: 5269 },
{ from: 'Revenues', to: 'Gross profit', size: 4105 },
{ from: 'Gross profit', to: 'Selling and administrative expense', size: 3142 },
{ from: 'Gross profit', to: 'Interest expense', size: 14 },
{ from: 'Gross profit', to: 'Income before taxes', size: 949 },
{ from: 'Other income', to: 'Income before taxes', size: 48 },
{ from: 'Selling and administrative expense', to: 'Demand creation expense', size: 910 },
{ from: 'Selling and administrative expense', to: 'Operating overhead expense', size: 2232 },
{ from: 'Income before taxes', to: 'Tax expense', size: 150 },
{ from: 'Income before taxes', to: 'Net income', size: 847 },
],
series: [
{
type: 'sankey',
fromKey: 'from',
toKey: 'to',
sizeKey: 'size',
sizeName: 'Total (USD millions)',
node: {
alignment: 'center',
sort: 'weight',
},
},
],
};

const chart = AgCharts.create(options);

function sortData() {
(options.series![0] as AgSankeySeriesOptions).node!.sort = 'data';
chart.update(options);
}

function sortAZ() {
(options.series![0] as AgSankeySeriesOptions).node!.sort = 'a-z';
chart.update(options);
}

function sortZA() {
(options.series![0] as AgSankeySeriesOptions).node!.sort = 'z-a';
chart.update(options);
}

function sortWeight() {
(options.series![0] as AgSankeySeriesOptions).node!.sort = 'weight';
chart.update(options);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="example-controls">
<div class="controls-row">
<button onclick="verticalAlignTop()"><code>'top'</code></button>
<button onclick="verticalAlignBottom()"><code>'bottom'</code></button>
<button onclick="verticalAlignCenter()"><code>'center'</code></button>
</div>
</div>
<div id="myChart"></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { AgCharts, AgFlowProportionChartOptions, AgSankeySeriesOptions } from 'ag-charts-enterprise';

const options: AgFlowProportionChartOptions = {
container: document.getElementById('myChart'),
title: {
text: 'Company Revenue',
},
subtitle: {
text: '2023',
},
data: [
{ from: 'Employees', to: 'Sales', size: 2 },
{ from: 'Contractors', to: 'Sales', size: 2 },
{ from: 'Sales', to: 'Revenue', size: 4 },
{ from: 'Licenses', to: 'Revenue', size: 4 },
{ from: 'Revenue', to: 'Cost of Sales', size: 1 },
{ from: 'Revenue', to: 'Profit', size: 7 },
{ from: 'Profit', to: 'Other Expenses', size: 2 },
{ from: 'Profit', to: 'Operational Profit', size: 5 },
{ from: 'Operational Profit', to: 'Shareholders', size: 3 },
{ from: 'Operational Profit', to: 'Employee Bonuses', size: 2 },
],
series: [
{
type: 'sankey',
fromKey: 'from',
toKey: 'to',
sizeKey: 'size',
sizeName: 'Total (USD millions)',
node: {
verticalAlignment: 'center',
},
},
],
};

const chart = AgCharts.create(options);

function verticalAlignTop() {
(options.series![0] as AgSankeySeriesOptions).node!.verticalAlignment = 'top';
chart.update(options);
}

function verticalAlignBottom() {
(options.series![0] as AgSankeySeriesOptions).node!.verticalAlignment = 'bottom';
chart.update(options);
}

function verticalAlignCenter() {
(options.series![0] as AgSankeySeriesOptions).node!.verticalAlignment = 'center';
chart.update(options);
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ There are four values supported:
- `center` moves nodes as close to the centre as possible.
- `justify` moves nodes as far left as possible, except for the last nodes, which are pushed right.

## Vertical alignment

The vertical placement of the nodes can be customised using the `verticalAlignment` property on `node`.

{% chartExampleRunner title="Vertical alignment" name="vertical-alignment" type="generated" /%}

```js format="snippet"
{
series: [
{
type: 'sankey',
fromKey: 'from',
toKey: 'to',
sizeKey: 'size',
node: {
verticalAlignment: 'top',
},
},
],
}
```

## Sorting

The order of the nodes can be customised using the `sort` property on `node`.

{% chartExampleRunner title="Sorting" name="sorting" type="generated" /%}

There are four values supported:

- `data` sorts nodes in the same order as defined in the `data` array.
- `a-z` and `z-a` sort nodes alphanumerically by their labels.
- `weight` sorts nodes to minimize cross-overs of links by weighting neighbours by their size and their linked neighbours.

## Customisation

### Node Style
Expand Down
Loading