Skip to content

Commit a87ebec

Browse files
Merge pull request #3 from IsmaelMasharo/reusable-chart-pattern
Reusable chart pattern
2 parents 85eb40b + 3db7bab commit a87ebec

7 files changed

Lines changed: 3654 additions & 2922 deletions

File tree

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
src/Error.tsx
2+
src/Sankey.js
3+
src/SankeyPanel.tsx

sankey-panel.zip

7.83 KB
Binary file not shown.

src/Error.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// @ts-nocheck
2+
import React from 'react';
3+
import { Icon } from '@grafana/ui';
4+
5+
export const ErrorMessage = ({ message }) => (
6+
<p style={panelStyles}>
7+
<div style={containerStyles}>
8+
<Icon name='exclamation-triangle' />
9+
<div style={messageStyles}>{message}</div>
10+
</div>
11+
</p>
12+
)
13+
14+
const panelStyles = {
15+
height: '100%',
16+
display: 'flex',
17+
justifyContent: 'center',
18+
alignItems: 'center'
19+
}
20+
21+
const containerStyles = {
22+
padding: '15px 20px',
23+
marginBottom: '4px',
24+
position: 'relative',
25+
color: 'rgb(255, 255, 255)',
26+
textShadow: 'rgb(0 0 0 / 20%) 0px 1px 0px',
27+
borderRadius: '3px',
28+
display: 'flex',
29+
flexDirection: 'row',
30+
alignItems: 'center',
31+
background: 'linear-gradient(90deg, rgb(224, 47, 68), rgb(224, 47, 68))'
32+
}
33+
34+
const messageStyles = {
35+
marginLeft: 10
36+
}

src/Sankey.js

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
import * as d3 from 'd3';
2+
import * as d3Sankey from 'd3-sankey';
3+
4+
const DISPLAY_VALUES = { total: 'total', percentage: 'percentage', both: 'both', none: 'none' };
5+
const EDGE_COLORS = { none: 'none', path: 'path', input: 'input', output: 'output'};
6+
7+
export class Sankey {
8+
constructor(svg, container) {
9+
this._svg = svg;
10+
this._container = container || svg;
11+
this._gBound = null;
12+
13+
this._data = null;
14+
this._nodes = null;
15+
this._links = null;
16+
17+
this._width = 0;
18+
this._height = 0;
19+
this._boundedWidth = 0;
20+
this._boundedHeight = 0;
21+
22+
this._marginTop = 20;
23+
this._marginRight = 20;
24+
this._marginBottom = 20;
25+
this._marginLeft = 20;
26+
27+
this._background = '#f8f8fa';
28+
this._edgeColor = 'path';
29+
this._colorScheme = 'Tableau10';
30+
this._colorScale = null;
31+
32+
this._sankeyAlignType = 'Justify';
33+
this._sankeyAlign = null;
34+
this._sankeyGenerator = null;
35+
this._sankeyNodeWith = 15;
36+
this._sankeyNodePadding = 20;
37+
38+
this._svgNode = null;
39+
this._svgLink = null;
40+
41+
this._displayValues = 'none';
42+
this._highlightOnHover = false;
43+
44+
}
45+
46+
_init() {
47+
this._setBoundDimensions();
48+
this._setColorScale();
49+
this._configureSankey();
50+
this._calculateSankey();
51+
}
52+
53+
// ---------------------------- DIMENSIONS ----------------------------
54+
55+
_setBoundDimensions() {
56+
this._boundedWidth = this._width - this._marginLeft - this._marginRight;
57+
this._boundedHeight = this._height - this._marginTop - this._marginBottom;
58+
}
59+
60+
// ------------------------------ COLOR -------------------------------
61+
62+
_setColorScale() {
63+
this._colorScale = d3.scaleOrdinal(d3[`scheme${this._colorScheme}`]);
64+
}
65+
66+
_color(node) {
67+
return this._colorScale(node.name);
68+
}
69+
70+
// ------------------------------ SANKEY -------------------------------
71+
72+
_configureSankey() {
73+
this._sankeyAlign = d3Sankey[`sankey${this._sankeyAlignType}`];
74+
75+
this._sankeyGenerator = d3Sankey
76+
.sankey()
77+
.nodeId(d => d.name)
78+
.nodeAlign(this._sankeyAlign)
79+
.nodeWidth(this._sankeyNodeWith)
80+
.nodePadding(this._sankeyNodePadding)
81+
.extent([
82+
[0, 0],
83+
[this._boundedWidth, this._boundedHeight],
84+
]);
85+
}
86+
87+
_calculateSankey() {
88+
const sankeyData = this._sankeyGenerator({
89+
nodes: this._data.nodes.map(d => Object.assign({}, d)),
90+
links: this._data.links.map(d => Object.assign({}, d))
91+
});
92+
93+
this._nodes = sankeyData.nodes;
94+
this._links = sankeyData.links;
95+
}
96+
97+
// ---------------------------- VALIDATIONS -----------------------------
98+
99+
_validate() {
100+
return this._data &&
101+
this._data.nodes &&
102+
this._data.links &&
103+
this._data.nodes.length > 0 &&
104+
this._data.links.length > 0
105+
}
106+
107+
// ------------------------------ HELPERS -------------------------------
108+
109+
_setLinkGradient() {
110+
const gradient = this._svgLink
111+
.append('linearGradient')
112+
.attr('id', d => (d.uid = `link-${d.index}-${Math.random()}`))
113+
.attr('gradientUnits', 'userSpaceOnUse')
114+
.attr('x1', d => d.source.x1)
115+
.attr('x2', d => d.target.x0);
116+
117+
gradient
118+
.append('stop')
119+
.attr('offset', '0%')
120+
.attr('stop-color', d => this._color(d.source));
121+
122+
gradient
123+
.append('stop')
124+
.attr('offset', '100%')
125+
.attr('stop-color', d => this._color(d.target));
126+
}
127+
128+
_setLinkStroke(d) {
129+
switch (this._edgeColor) {
130+
case EDGE_COLORS.none:
131+
return '#aaa';
132+
case EDGE_COLORS.path:
133+
return `url(#${d.uid})`;
134+
case EDGE_COLORS.input:
135+
return this._color(d.source)
136+
case EDGE_COLORS.output:
137+
return this._color(d.target)
138+
default:
139+
return
140+
}
141+
}
142+
143+
// NODE HOVER
144+
_showLinks(currentNode) {
145+
const linkedNodes = [];
146+
147+
let traverse = [
148+
{
149+
linkType: 'sourceLinks',
150+
nodeType: 'target',
151+
},
152+
{
153+
linkType: 'targetLinks',
154+
nodeType: 'source',
155+
},
156+
];
157+
158+
traverse.forEach(step => {
159+
currentNode[step.linkType].forEach(l => {
160+
linkedNodes.push(l[step.nodeType]);
161+
});
162+
});
163+
164+
// highlight linked nodes
165+
this._gBound
166+
.selectAll('.sankey-node')
167+
.style('opacity', node =>
168+
currentNode.name === node.name ||
169+
linkedNodes.find(linkedNode => linkedNode.name === node.name) ?
170+
'1' : '0.2'
171+
);
172+
173+
// highlight links
174+
this._gBound
175+
.selectAll('.sankey-link')
176+
.style('opacity', link =>
177+
link && (
178+
link.source.name === currentNode.name ||
179+
link.target.name === currentNode.name
180+
) ?
181+
'1' : '0.2'
182+
);
183+
};
184+
185+
_showAll() {
186+
this._gBound.selectAll('.sankey-node').style('opacity', '1');
187+
this._gBound.selectAll('.sankey-link').style('opacity', '1');
188+
};
189+
190+
// NODE LABELING
191+
_formatValue(value) { return d3.format('.2~f')(value); }
192+
_formatPercent(percent) { return d3.format('.2~%')(percent); }
193+
_formatThousand(value) { return d3.format('.3~s')(value); }
194+
195+
_labelNode(currentNode) {
196+
const nodesAtDepth = this._nodes.filter(node => node.depth === currentNode.depth);
197+
const totalAtDepth = d3.sum(nodesAtDepth, node => node.value);
198+
const nodeValue = this._formatThousand(currentNode.value);
199+
const nodePercent = this._formatPercent(currentNode.value / totalAtDepth);
200+
201+
let label = currentNode.name;
202+
switch (this._displayValues) {
203+
case DISPLAY_VALUES.total:
204+
label = `${label}: ${nodeValue}`;
205+
break;
206+
case DISPLAY_VALUES.percentage:
207+
label = `${label}: ${nodePercent}`;
208+
break;
209+
case DISPLAY_VALUES.both:
210+
label = `${label}: ${nodePercent} - ${nodeValue}`;
211+
break;
212+
default:
213+
break;
214+
}
215+
return label;
216+
};
217+
218+
// ------------------------------ DRAWING -------------------------------
219+
220+
_renderSVG() {
221+
// BACKGROUND
222+
this._container.style('background-color', this._background)
223+
224+
// BOUNDS
225+
this._gBound = this._container.append('g')
226+
.attr('transform', `translate(${this._marginLeft}, ${this._marginTop})`);
227+
228+
// NODES
229+
this._svgNode = this._gBound
230+
.append('g')
231+
.attr('stroke', '#000')
232+
.selectAll('.sankey-node')
233+
.data(this._nodes, node => node.name)
234+
.join('rect')
235+
.attr('class', 'sankey-node')
236+
.attr('x', d => d.x0)
237+
.attr('y', d => d.y0)
238+
.attr('rx', 2)
239+
.attr('ry', 2)
240+
.attr('height', d => d.y1 - d.y0)
241+
.attr('width', d => d.x1 - d.x0)
242+
.attr('stroke', d => d3.color(this._color(d)).darker(0.5))
243+
.attr('fill', d => this._color(d))
244+
.on('mouseover', d => this._highlightOnHover && this._showLinks(d))
245+
.on('mouseout', _ => this._highlightOnHover && this._showAll());
246+
247+
// LINKS
248+
this._svgLink = this._gBound
249+
.append('g')
250+
.attr('fill', 'none')
251+
.attr('stroke-opacity', 0.3)
252+
.selectAll('g')
253+
.data(this._links, link => `${link.source.name}-${link.target.name}`)
254+
.join('g')
255+
.style('mix-blend-mode', 'multiply');
256+
257+
if (this._edgeColor === 'path') this._setLinkGradient()
258+
259+
this._svgLink
260+
.append('path')
261+
.attr('class', 'sankey-link')
262+
.attr('d', d3Sankey.sankeyLinkHorizontal())
263+
.attr('stroke', d => this._setLinkStroke(d))
264+
.attr('stroke-width', d => Math.max(1, d.width));
265+
266+
// LABELS
267+
this._gBound
268+
.append('g')
269+
.attr('font-family', 'sans-serif')
270+
.attr('font-size', 10)
271+
.selectAll('text')
272+
.data(this._nodes)
273+
.join('text')
274+
.attr('x', d => (d.x0 < this._width / 2 ? d.x1 + 6 : d.x0 - 6))
275+
.attr('y', d => (d.y1 + d.y0) / 2)
276+
.attr('dy', '0.35em')
277+
.attr('text-anchor', d => (d.x0 < this._width / 2 ? 'start' : 'end'))
278+
.text(d => this._labelNode(d));
279+
280+
this._svgNode
281+
.append('title')
282+
.text(d => `${d.name}\n${this._formatValue(d.value)}`);
283+
284+
this._svgLink
285+
.append('title')
286+
.text(d => `${d.source.name}${d.target.name}\n${this._formatValue(d.value)}`);
287+
}
288+
289+
290+
// -----------------------------------------------------------------------
291+
// ------------------------------ API ------------------------------
292+
// -----------------------------------------------------------------------
293+
294+
data(_) {
295+
return arguments.length ? (this._data = _, this) : this._data;
296+
};
297+
298+
width(_) {
299+
return arguments.length ? (this._width = +_, this) : this._width;
300+
};
301+
302+
height(_) {
303+
return arguments.length ? (this._height = +_, this) : this._height;
304+
};
305+
306+
align(_) {
307+
return arguments.length ? (this._sankeyAlignType = _, this) : this._sankeyAlignType;
308+
}
309+
310+
colorScheme(_) {
311+
return arguments.length ? (this._colorScheme = _, this) : this._colorScheme;
312+
}
313+
314+
edgeColor(_) {
315+
return arguments.length ? (this._edgeColor = _, this) : this._edgeColor;
316+
}
317+
318+
displayValues(_) {
319+
return arguments.length ? (this._displayValues = _, this) : this._displayValues;
320+
}
321+
322+
highlightOnHover(_) {
323+
return arguments.length ? (this._highlightOnHover = _, this) : this._highlightOnHover;
324+
}
325+
326+
render() {
327+
if (!this._validate()) {
328+
// no graph data
329+
}
330+
else {
331+
this._init();
332+
this._renderSVG()
333+
}
334+
return this;
335+
}
336+
}

0 commit comments

Comments
 (0)