Skip to content

Commit

Permalink
fix: zero size node padding issue (#161)
Browse files Browse the repository at this point in the history
  • Loading branch information
kurkle authored Oct 27, 2024
1 parent 2257310 commit f11c9de
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 81 deletions.
33 changes: 6 additions & 27 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ import {
DatasetController,
FromToElement,
SankeyControllerDatasetOptions,
SankeyDataPoint,
SankeyNode,
SankeyParsedData,
} from 'chart.js'
import { toFont, valueOrDefault } from 'chart.js/helpers'

import { AnyObject } from '../types/index.esm'

import { buildNodesFromData } from './lib/core'
import { buildNodesFromData, getParsedData } from './lib/core'
import { toTextLines, validateSizeValue } from './lib/helpers'
import { layout } from './lib/layout'
import Flow from './flow'
Expand Down Expand Up @@ -136,33 +135,13 @@ export default class SankeyController extends DatasetController {
start: number,
count: number
): SankeyParsedData[] {
const { from: fromKey = 'from', to: toKey = 'to', flow: flowKey = 'flow' } = this.options.parsing
const sankeyData = data.map(
({ [fromKey]: from, [toKey]: to, [flowKey]: flow }) => ({ from, to, flow }) as SankeyDataPoint
)
const sankeyData = getParsedData(data, this.options.parsing)
const { xScale, yScale } = meta
const parsed: SankeyParsedData[] = []
const nodes = (this._nodes = buildNodesFromData(sankeyData))
const { column, priority, size } = this.options
if (priority) {
for (const node of nodes.values()) {
if (node.key in priority) {
node.priority = priority[node.key]
}
}
}
if (column) {
for (const node of nodes.values()) {
if (node.key in column) {
node.column = true
node.x = column[node.key]
}
}
}
const nodes = (this._nodes = buildNodesFromData(sankeyData, this.options))

const { maxX, maxY } = layout(nodes, sankeyData, {
priority: !!priority,
size: validateSizeValue(size),
priority: !!this.options.priority,
height: this.chart.canvas.height,
nodePadding: this.options.nodePadding,
modeX: this.options.modeX,
Expand Down Expand Up @@ -269,7 +248,7 @@ export default class SankeyController extends DatasetController {
const y = yScale.getPixelForValue(node.y)

const max = Math[size](node.in || node.out, node.out || node.in)
const height = Math.abs(yScale.getPixelForValue(node.y! + max) - y)
const height = Math.abs(yScale.getPixelForValue(node.y + max) - y)
const label = labels?.[node.key] ?? node.key
let textX = x
ctx.fillStyle = options.color ?? 'black'
Expand Down Expand Up @@ -325,7 +304,7 @@ export default class SankeyController extends DatasetController {
const y = yScale!.getPixelForValue(node.y)

const max = Math[sizeMethod](node.in || node.out, node.out || node.in)
const height = Math.abs(yScale!.getPixelForValue(node.y! + max) - y)
const height = Math.abs(yScale!.getPixelForValue(node.y + max) - y)
if (borderWidth) {
ctx.strokeRect(x, y, nodeWidth, height)
}
Expand Down
19 changes: 11 additions & 8 deletions src/lib/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('lib/core', () => {
test('it should build nodes from simple flows', () => {
const data: SankeyDataPoint[] = [{ from: 'a', to: 'b', flow: 1 }]

const nodes = buildNodesFromData(data)
const nodes = buildNodesFromData(data, {})

expect(nodes.size).toEqual(2)
expect([...nodes.keys()]).toEqual(['a', 'b'])
Expand All @@ -17,6 +17,7 @@ describe('lib/core', () => {
in: 0,
key: 'a',
out: 1,
size: 1,
to: [
{
addY: 0,
Expand All @@ -40,6 +41,7 @@ describe('lib/core', () => {
in: 1,
key: 'b',
out: 0,
size: 1,
to: [],
})
})
Expand All @@ -55,7 +57,7 @@ describe('lib/core', () => {
{ from: 'Solid', to: 'Agriculture', flow: 0.882 },
]

const nodes = buildNodesFromData(data)
const nodes = buildNodesFromData(data, {})

expect(nodes.size).toEqual(8)

Expand All @@ -72,7 +74,7 @@ describe('lib/core', () => {
test('it should support circular flows', () => {
const data: SankeyDataPoint[] = [{ from: 'abba', to: 'abba', flow: 123.5 }]

const nodes = buildNodesFromData(data)
const nodes = buildNodesFromData(data, {})

expect(nodes.size).toBe(1)
expect(nodes.get('abba')).toEqual(
Expand All @@ -81,21 +83,22 @@ describe('lib/core', () => {
in: 123.5,
key: 'abba',
out: 123.5,
size: 123.5,
to: [expect.any(Object)], // circular
})
)
})

test('it should ignore data with no flow', () => {
test('it should include data with no flow', () => {
const data: SankeyDataPoint[] = [
{ from: 'one', to: 'other', flow: 0 },
{ from: 'one', to: 'third', flow: 2 },
]

const nodes = buildNodesFromData(data)
const nodes = buildNodesFromData(data, {})

expect(nodes.size).toBe(2)
expect([...nodes.keys()]).toEqual(['one', 'third'])
expect(nodes.size).toBe(3)
expect([...nodes.keys()]).toEqual(['one', 'other', 'third'])
})

test('it should sort flows', () => {
Expand All @@ -110,7 +113,7 @@ describe('lib/core', () => {
{ from: 'b', to: 'c4', flow: 3 },
]

const nodes = buildNodesFromData(data)
const nodes = buildNodesFromData(data, {})

expect(nodes.size).toBe(9)

Expand Down
48 changes: 43 additions & 5 deletions src/lib/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { FromToElement, SankeyDataPoint, SankeyNode } from 'chart.js'
import { FromToElement, SankeyControllerDatasetOptions, SankeyDataPoint, SankeyNode } from 'chart.js'
import { AnyObject } from 'types/index.esm'

import { validateSizeValue } from './helpers'

const flowSort = (a: FromToElement, b: FromToElement) => {
// In case the flows are equal, keep original order
Expand All @@ -7,18 +10,46 @@ const flowSort = (a: FromToElement, b: FromToElement) => {
return b.flow - a.flow
}

export function buildNodesFromData(data: SankeyDataPoint[]): Map<string, SankeyNode> {
const setPriorities = (nodes: Map<string, SankeyNode>, priority: SankeyControllerDatasetOptions['priority']) => {
if (!priority) return

for (const node of nodes.values()) {
if (node.key in priority) {
node.priority = priority[node.key]
}
}
}

const setColumns = (nodes: Map<string, SankeyNode>, column: SankeyControllerDatasetOptions['column']) => {
if (!column) return

for (const node of nodes.values()) {
if (node.key in column) {
node.column = true
node.x = column[node.key]
}
}
}

export const getParsedData = (data: AnyObject[], parsing: SankeyControllerDatasetOptions['parsing']) => {
const { from: fromKey = 'from', to: toKey = 'to', flow: flowKey = 'flow' } = parsing

return data.map(({ [fromKey]: from, [toKey]: to, [flowKey]: flow }) => ({ from, to, flow }) as SankeyDataPoint)
}

export function buildNodesFromData(
data: SankeyDataPoint[],
{ size, priority, column }: Pick<SankeyControllerDatasetOptions, 'size' | 'priority' | 'column'>
): Map<string, SankeyNode> {
const nodes = new Map<string, SankeyNode>()
for (let i = 0; i < data.length; i++) {
const { from, to, flow } = data[i]

// ignore zero or negative flows
if (flow <= 0) continue

const fromNode: SankeyNode = nodes.get(from) ?? {
key: from,
in: 0,
out: 0,
size: 0,
from: [],
to: [],
}
Expand All @@ -27,6 +58,7 @@ export function buildNodesFromData(data: SankeyDataPoint[]): Map<string, SankeyN
key: to,
in: 0,
out: 0,
size: 0,
from: [],
to: [],
}
Expand All @@ -44,10 +76,16 @@ export function buildNodesFromData(data: SankeyDataPoint[]): Map<string, SankeyN
}
}

const sizeMethod = validateSizeValue(size)

for (const node of nodes.values()) {
node.from.sort(flowSort)
node.to.sort(flowSort)
node.size = Math[sizeMethod](node.in || node.out, node.out || node.in)
}

setPriorities(nodes, priority)
setColumns(nodes, column)

return nodes
}
40 changes: 20 additions & 20 deletions src/lib/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ describe('lib/layout', () => {
describe('addPadding', () => {
test('when there is a single row of nodes, it should not add any paddings', () => {
const nodes = [
{ x: 0, y: 0, in: 0, out: 8 },
{ x: 1, y: 0, in: 8, out: 10 },
{ x: 2, y: 0, in: 10, out: 0 },
{ x: 0, y: 0, in: 0, out: 8, size: 8 },
{ x: 1, y: 0, in: 8, out: 10, size: 10 },
{ x: 2, y: 0, in: 10, out: 0, size: 10 },
]

// maxY equals max flow
Expand All @@ -20,11 +20,11 @@ describe('lib/layout', () => {

test('when there are multiple rows of nodes, it should add paddings', () => {
const nodes = [
{ x: 0, y: 0, in: 0, out: 8 },
{ x: 0, y: 8, in: 0, out: 5 },
{ x: 0, y: 13, in: 0, out: 5 },
{ x: 1, y: 0, in: 13, out: 0 },
{ x: 1, y: 13, in: 5, out: 0 },
{ x: 0, y: 0, in: 0, out: 8, size: 8 },
{ x: 0, y: 8, in: 0, out: 5, size: 5 },
{ x: 0, y: 13, in: 0, out: 5, size: 5 },
{ x: 1, y: 0, in: 13, out: 0, size: 13 },
{ x: 1, y: 13, in: 5, out: 0, size: 5 },
]

// 18 + 2x padding
Expand All @@ -36,21 +36,21 @@ describe('lib/layout', () => {

test('it should consider previous columns, when node has input', () => {
const nodes = [
{ x: 0, y: 0, in: 0, out: 1 },
{ x: 0, y: 1, in: 0, out: 1 },
{ x: 0, y: 2, in: 0, out: 1 },
{ x: 0, y: 3, in: 0, out: 1 },
{ x: 0, y: 0, in: 0, out: 1, size: 1 },
{ x: 0, y: 1, in: 0, out: 1, size: 1 },
{ x: 0, y: 2, in: 0, out: 1, size: 1 },
{ x: 0, y: 3, in: 0, out: 1, size: 1 },

{ x: 1, y: 4, in: 0, out: 1 },
{ x: 1, y: 4, in: 0, out: 1, size: 1 },

{ x: 2, y: 0, in: 3, out: 3 },
{ x: 2, y: 2, in: 2, out: 2 },
{ x: 2, y: 0, in: 3, out: 3, size: 3 },
{ x: 2, y: 2, in: 2, out: 2, size: 2 },

{ x: 3, y: 0, in: 1, out: 0 },
{ x: 3, y: 1, in: 1, out: 0 },
{ x: 3, y: 2, in: 1, out: 0 },
{ x: 3, y: 3, in: 1, out: 0 },
{ x: 3, y: 4, in: 1, out: 0 },
{ x: 3, y: 0, in: 1, out: 0, size: 1 },
{ x: 3, y: 1, in: 1, out: 0, size: 1 },
{ x: 3, y: 2, in: 1, out: 0, size: 1 },
{ x: 3, y: 3, in: 1, out: 0, size: 1 },
{ x: 3, y: 4, in: 1, out: 0, size: 1 },
]

// 5 + 4x padding
Expand Down
Loading

0 comments on commit f11c9de

Please sign in to comment.