tape-six 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/package.json +7 -8
- package/webApp/DashReporter.js +114 -0
- package/webApp/DomReporter.js +160 -0
- package/webApp/TestWorker.js +54 -0
- package/webApp/donut.js +235 -0
- package/webApp/index.html +80 -0
- package/webApp/index.js +114 -0
- package/webApp/style.css +231 -0
- package/webApp/tape6-donut.css +21 -0
- package/webApp/tape6-donut.js +26 -0
- package/webApp/tape6-hflip.css +34 -0
- package/webApp/tape6-hflip.js +37 -0
- package/webApp/tape6-spinner.css +41 -0
- package/webApp/tape6-spinner.js +73 -0
- package/webApp/tape6-toggle.css +95 -0
package/README.md
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tape-six [![NPM version][npm-img]][npm-url]
|
|
2
2
|
|
|
3
|
-
[npm-img]: https://img.shields.io/npm/v/
|
|
4
|
-
[npm-url]: https://npmjs.org/package/
|
|
3
|
+
[npm-img]: https://img.shields.io/npm/v/tape-six.svg
|
|
4
|
+
[npm-url]: https://npmjs.org/package/tape-six
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
tape-six is a [TAP](https://en.wikipedia.org/wiki/Test_Anything_Protocol)-based library for unit tests. It is written in modern ES6 and works in [node](https://nodejs.org/), [deno](https://deno.land/) and browsers.
|
|
7
|
+
|
|
8
|
+
Why `tape-six`? It was supposed to be named `tape6` but `npm` does not allow names "similar" to existing packages. Instead of eliminating name-squatting they force to use unintuitive and unmemorable names. That's why all internal names, environment variables, and public names still use `tape6`.
|
|
9
|
+
|
|
10
|
+
Why another library? Working on projects written in modern JS (with modules) I found two problems with existing unit test libraries:
|
|
11
|
+
|
|
12
|
+
* In my opinion unit test files should be directly executable with `node`, `deno`, browsers (with a trivial HTML file to load a test file) without a need for a special test runner utility, which wraps and massages my beautiful code.
|
|
13
|
+
* Some of them do not work with modules.
|
|
14
|
+
* Some of them have the abysmal developer experience in browsers.
|
|
7
15
|
|
|
8
16
|
The documentation is TBD but you can inspect `tests/` to see how it is used.
|
|
9
17
|
If you are familiar with other TAP-based libraries you'll feel right at home.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tape-six",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "TAP for the modern JavaScript (ES6).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"build": "npm run copyDeep6",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
|
-
"github": "http://github.com/uhop/
|
|
22
|
+
"github": "http://github.com/uhop/tape-six",
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
25
|
-
"url": "git+https://github.com/uhop/
|
|
25
|
+
"url": "git+https://github.com/uhop/tape-six.git"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
|
28
28
|
"tap",
|
|
@@ -34,15 +34,14 @@
|
|
|
34
34
|
"author": "Eugene Lazutkin <eugene.lazutkin@gmail.com> (https://www.lazutkin.com/)",
|
|
35
35
|
"license": "BSD-3-Clause",
|
|
36
36
|
"bugs": {
|
|
37
|
-
"url": "https://github.com/uhop/
|
|
37
|
+
"url": "https://github.com/uhop/tape-six/issues"
|
|
38
38
|
},
|
|
39
|
-
"homepage": "https://github.com/uhop/
|
|
39
|
+
"homepage": "https://github.com/uhop/tape-six#readme",
|
|
40
40
|
"files": [
|
|
41
41
|
"index.js",
|
|
42
42
|
"bin",
|
|
43
|
-
"
|
|
44
|
-
"src"
|
|
45
|
-
"cjs"
|
|
43
|
+
"webApp",
|
|
44
|
+
"src"
|
|
46
45
|
],
|
|
47
46
|
"tape6": {
|
|
48
47
|
"tests": [
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {formatNumber} from '../src/utils/formatters.js';
|
|
2
|
+
|
|
3
|
+
const tagsToReplace = {'&': '&', '<': '<', '>': '>'};
|
|
4
|
+
const escapeHtml = string => string.replace(/[&<>]/g, tag => tagsToReplace[tag] || tag);
|
|
5
|
+
|
|
6
|
+
const formatName = event =>
|
|
7
|
+
'<span class="' +
|
|
8
|
+
(event.fail ? 'text-failure' : 'text-success') +
|
|
9
|
+
'">' +
|
|
10
|
+
((event.skip && '<span class="text-skipped">SKIP</span> ') || '') +
|
|
11
|
+
((event.todo && '<span class="text-todo">TODO</span> ') || '') +
|
|
12
|
+
escapeHtml(event.name || '') +
|
|
13
|
+
'</span>';
|
|
14
|
+
|
|
15
|
+
class DashReporter {
|
|
16
|
+
constructor({renumberAsserts = false} = {}) {
|
|
17
|
+
this.renumberAsserts = renumberAsserts;
|
|
18
|
+
this.assertCounter = 0;
|
|
19
|
+
this.depth = this.assertCounter = this.failureCounter = this.skipCounter = this.todoCounter = 0;
|
|
20
|
+
this.testCounter = -1;
|
|
21
|
+
this.currentTest = this.lastAssert = '';
|
|
22
|
+
this.scoreNode = document.querySelector('.tape6 .score');
|
|
23
|
+
this.donutNode = this.scoreNode && this.scoreNode.querySelector('tape6-donut');
|
|
24
|
+
this.legendNode = document.querySelector('.tape6 .legend');
|
|
25
|
+
this.spinnerNode = document.querySelector('.tape6 tape6-spinner');
|
|
26
|
+
this.running = true;
|
|
27
|
+
}
|
|
28
|
+
report(event) {
|
|
29
|
+
switch (event.type) {
|
|
30
|
+
case 'test':
|
|
31
|
+
++this.depth;
|
|
32
|
+
++this.testCounter;
|
|
33
|
+
this.currentTest = formatName(event);
|
|
34
|
+
this.updateDashboard();
|
|
35
|
+
break;
|
|
36
|
+
case 'end':
|
|
37
|
+
if (!--this.depth) {
|
|
38
|
+
this.running = false;
|
|
39
|
+
this.updateDashboard();
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
case 'assert':
|
|
43
|
+
++this.assertCounter;
|
|
44
|
+
event.fail && !event.skip && !event.todo && ++this.failureCounter;
|
|
45
|
+
event.skip && ++this.skipCounter;
|
|
46
|
+
event.todo && ++this.todoCounter;
|
|
47
|
+
this.lastAssert = formatName(event);
|
|
48
|
+
this.updateDashboard();
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
updateDashboard() {
|
|
53
|
+
this.updateDonut();
|
|
54
|
+
this.updateLegend();
|
|
55
|
+
this.updateStatus();
|
|
56
|
+
this.updateScoreCard();
|
|
57
|
+
}
|
|
58
|
+
updateDonut() {
|
|
59
|
+
if (!this.donutNode) return;
|
|
60
|
+
const total = this.assertCounter - this.skipCounter,
|
|
61
|
+
success = total - this.failureCounter;
|
|
62
|
+
this.donutNode.show([
|
|
63
|
+
{value: success, className: 'success'},
|
|
64
|
+
{value: this.failureCounter, className: 'failure'},
|
|
65
|
+
{value: this.skipCounter, className: 'skipped'},
|
|
66
|
+
{value: this.todoCounter, className: 'todo'}
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
updateLegend() {
|
|
70
|
+
if (!this.legendNode) return;
|
|
71
|
+
const total = this.assertCounter - this.skipCounter,
|
|
72
|
+
success = total - this.failureCounter;
|
|
73
|
+
let node = this.legendNode.querySelector('.legend-tests .value');
|
|
74
|
+
node && (node.innerHTML = formatNumber(this.testCounter));
|
|
75
|
+
node = this.legendNode.querySelector('.legend-asserts .value');
|
|
76
|
+
node && (node.innerHTML = formatNumber(this.assertCounter));
|
|
77
|
+
node = this.legendNode.querySelector('.legend-success .value');
|
|
78
|
+
node && (node.innerHTML = formatNumber(success));
|
|
79
|
+
node = this.legendNode.querySelector('.legend-failure .value');
|
|
80
|
+
node && (node.innerHTML = formatNumber(this.failureCounter));
|
|
81
|
+
node = this.legendNode.querySelector('.legend-skipped .value');
|
|
82
|
+
node && (node.innerHTML = formatNumber(this.skipCounter));
|
|
83
|
+
node = this.legendNode.querySelector('.legend-todo .value');
|
|
84
|
+
node && (node.innerHTML = formatNumber(this.todoCounter));
|
|
85
|
+
}
|
|
86
|
+
updateStatus() {
|
|
87
|
+
if (!this.spinnerNode) return;
|
|
88
|
+
this.spinnerNode[this.running ? 'show' : 'hide']();
|
|
89
|
+
}
|
|
90
|
+
updateScoreCard() {
|
|
91
|
+
if (!this.scoreNode) return;
|
|
92
|
+
const total = this.assertCounter - this.skipCounter,
|
|
93
|
+
success = total - this.failureCounter,
|
|
94
|
+
fail = success < total,
|
|
95
|
+
result = total > 0 ? formatNumber(100 * (success / total), 1) : '100';
|
|
96
|
+
let node = this.scoreNode.querySelector('.text');
|
|
97
|
+
node.classList.remove('nothing');
|
|
98
|
+
if (fail) {
|
|
99
|
+
node.classList.remove('success-dark');
|
|
100
|
+
node.classList.add('failure-dark');
|
|
101
|
+
} else {
|
|
102
|
+
node.classList.remove('failure-dark');
|
|
103
|
+
node.classList.add('success-dark');
|
|
104
|
+
}
|
|
105
|
+
node = this.scoreNode.querySelector('.message');
|
|
106
|
+
while (node.lastChild) node.removeChild(node.lastChild);
|
|
107
|
+
node.appendChild(document.createTextNode(fail ? 'Need some work' : 'All good!'));
|
|
108
|
+
node = this.scoreNode.querySelector('.result');
|
|
109
|
+
while (node.lastChild) node.removeChild(node.lastChild);
|
|
110
|
+
node.appendChild(document.createTextNode(result + '% passed'));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default DashReporter;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const formatValue = value => {
|
|
2
|
+
if (typeof value == 'string') return value;
|
|
3
|
+
return JSON.stringify(value);
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
class DomReporter {
|
|
7
|
+
constructor({root, renumberAsserts = false} = {}) {
|
|
8
|
+
this.root = root;
|
|
9
|
+
this.renumberAsserts = renumberAsserts;
|
|
10
|
+
this.assertCounter = 0;
|
|
11
|
+
this.stack = []; // previous test nodes
|
|
12
|
+
this.current = null; // the current test node
|
|
13
|
+
}
|
|
14
|
+
report(event) {
|
|
15
|
+
let text;
|
|
16
|
+
switch (event.type) {
|
|
17
|
+
case 'test':
|
|
18
|
+
this.stack.push(this.current);
|
|
19
|
+
if (this.stack.length <= 1) break;
|
|
20
|
+
this.current = document.createElement('div');
|
|
21
|
+
this.current.className = 'test running';
|
|
22
|
+
{
|
|
23
|
+
const header = document.createElement('div');
|
|
24
|
+
header.className = 'header';
|
|
25
|
+
if (event.todo) {
|
|
26
|
+
const todo = document.createElement('span');
|
|
27
|
+
todo.className = 'text-todo';
|
|
28
|
+
todo.appendChild(document.createTextNode('TODO'));
|
|
29
|
+
header.appendChild(todo);
|
|
30
|
+
header.appendChild(document.createTextNode(' '));
|
|
31
|
+
}
|
|
32
|
+
header.appendChild(document.createTextNode(event.name));
|
|
33
|
+
this.current.appendChild(header);
|
|
34
|
+
}
|
|
35
|
+
((this.stack.length && this.stack[this.stack.length - 1]) || this.root).appendChild(this.current);
|
|
36
|
+
break;
|
|
37
|
+
case 'end':
|
|
38
|
+
if (this.current) {
|
|
39
|
+
this.current.classList.remove('running');
|
|
40
|
+
this.current.classList.add(event.fail ? 'failed' : 'passed');
|
|
41
|
+
}
|
|
42
|
+
this.current = this.stack.pop();
|
|
43
|
+
break;
|
|
44
|
+
case 'comment':
|
|
45
|
+
{
|
|
46
|
+
const comment = document.createElement('div');
|
|
47
|
+
comment.className = 'comment text-comment';
|
|
48
|
+
comment.appendChild(document.createTextNode(event.name));
|
|
49
|
+
(this.current || this.root).appendChild(comment);
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
case 'bail-out':
|
|
53
|
+
text = 'Bail out!';
|
|
54
|
+
event.name && (text += ' ' + event.name);
|
|
55
|
+
this.write(text, 'bail-out');
|
|
56
|
+
{
|
|
57
|
+
const bailOut = document.createElement('div');
|
|
58
|
+
bailOut.className = 'bail-out';
|
|
59
|
+
bailOut.appendChild(document.createTextNode(event.name));
|
|
60
|
+
this.root.appendChild(bailOut);
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
case 'assert':
|
|
64
|
+
{
|
|
65
|
+
const assert = document.createElement('div');
|
|
66
|
+
assert.className = 'assert ' + (event.fail ? 'failed' : 'passed');
|
|
67
|
+
const header = document.createElement('div');
|
|
68
|
+
header.className = 'header';
|
|
69
|
+
if (event.skip) {
|
|
70
|
+
const node = document.createElement('span');
|
|
71
|
+
node.className = 'text-skipped';
|
|
72
|
+
node.appendChild(document.createTextNode('SKIP'));
|
|
73
|
+
header.appendChild(node);
|
|
74
|
+
header.appendChild(document.createTextNode(' '));
|
|
75
|
+
}
|
|
76
|
+
if (event.todo) {
|
|
77
|
+
const node = document.createElement('span');
|
|
78
|
+
node.className = 'text-todo';
|
|
79
|
+
node.appendChild(document.createTextNode('TODO'));
|
|
80
|
+
header.appendChild(node);
|
|
81
|
+
header.appendChild(document.createTextNode(' '));
|
|
82
|
+
}
|
|
83
|
+
header.appendChild(document.createTextNode(event.name));
|
|
84
|
+
assert.appendChild(header);
|
|
85
|
+
if (event.operator) {
|
|
86
|
+
const row = document.createElement('div'),
|
|
87
|
+
name = document.createElement('span'),
|
|
88
|
+
value = document.createElement('code');
|
|
89
|
+
row.className = 'data';
|
|
90
|
+
name.className = 'name';
|
|
91
|
+
name.appendChild(document.createTextNode('operator:'));
|
|
92
|
+
value.className = 'value';
|
|
93
|
+
value.appendChild(document.createTextNode(event.operator));
|
|
94
|
+
row.appendChild(name);
|
|
95
|
+
row.appendChild(value);
|
|
96
|
+
assert.appendChild(row);
|
|
97
|
+
}
|
|
98
|
+
if (event.hasOwnProperty('expected')) {
|
|
99
|
+
const row = document.createElement('div'),
|
|
100
|
+
name = document.createElement('span'),
|
|
101
|
+
value = document.createElement('code');
|
|
102
|
+
row.className = 'data';
|
|
103
|
+
name.className = 'name';
|
|
104
|
+
name.appendChild(document.createTextNode('expected:'));
|
|
105
|
+
value.className = 'value';
|
|
106
|
+
value.appendChild(document.createTextNode(formatValue(event.expected)));
|
|
107
|
+
row.appendChild(name);
|
|
108
|
+
row.appendChild(value);
|
|
109
|
+
assert.appendChild(row);
|
|
110
|
+
}
|
|
111
|
+
if (event.hasOwnProperty('actual')) {
|
|
112
|
+
const row = document.createElement('div'),
|
|
113
|
+
name = document.createElement('span'),
|
|
114
|
+
value = document.createElement('code');
|
|
115
|
+
row.className = 'data';
|
|
116
|
+
name.className = 'name';
|
|
117
|
+
name.appendChild(document.createTextNode('actual:'));
|
|
118
|
+
value.className = 'value';
|
|
119
|
+
value.appendChild(document.createTextNode(formatValue(event.actual)));
|
|
120
|
+
row.appendChild(name);
|
|
121
|
+
row.appendChild(value);
|
|
122
|
+
assert.appendChild(row);
|
|
123
|
+
}
|
|
124
|
+
if (event.at) {
|
|
125
|
+
const row = document.createElement('div'),
|
|
126
|
+
name = document.createElement('span'),
|
|
127
|
+
value = document.createElement('code');
|
|
128
|
+
row.className = 'data';
|
|
129
|
+
name.className = 'name';
|
|
130
|
+
name.appendChild(document.createTextNode('at:'));
|
|
131
|
+
value.className = 'value';
|
|
132
|
+
value.appendChild(document.createTextNode(event.at));
|
|
133
|
+
row.appendChild(name);
|
|
134
|
+
row.appendChild(value);
|
|
135
|
+
assert.appendChild(row);
|
|
136
|
+
}
|
|
137
|
+
{
|
|
138
|
+
const stack = event.actual && event.actual.type === 'Error' && typeof event.actual.stack == 'string' ? event.actual.stack : event.marker.stack;
|
|
139
|
+
if (typeof stack == 'string') {
|
|
140
|
+
const row = document.createElement('div'),
|
|
141
|
+
name = document.createElement('span'),
|
|
142
|
+
value = document.createElement('pre');
|
|
143
|
+
row.className = 'data stack';
|
|
144
|
+
name.className = 'name';
|
|
145
|
+
name.appendChild(document.createTextNode('stack:'));
|
|
146
|
+
value.className = 'value';
|
|
147
|
+
stack.split('\n').forEach(line => value.appendChild(document.createTextNode(line + '\n')));
|
|
148
|
+
row.appendChild(name);
|
|
149
|
+
row.appendChild(value);
|
|
150
|
+
assert.appendChild(row);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
(this.current || this.root).appendChild(assert);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default DomReporter;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import EventServer from '../src/utils/EventServer.js';
|
|
2
|
+
|
|
3
|
+
export default class TestWorker extends EventServer {
|
|
4
|
+
constructor(...args) {
|
|
5
|
+
super(...args);
|
|
6
|
+
this.counter = 0;
|
|
7
|
+
window.__tape6_reporter = (id, event) => {
|
|
8
|
+
this.report(id, event);
|
|
9
|
+
if (event.type === 'end' && event.test === 0) this.close(id);
|
|
10
|
+
};
|
|
11
|
+
window.__tape6_error = (id, error) => {
|
|
12
|
+
error && this.report(id, {type: 'comment', name: 'fail to load: ' + error.message, test: 0});
|
|
13
|
+
this.close(id);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
makeTask(fileName) {
|
|
17
|
+
const id = String(++this.counter);
|
|
18
|
+
const iframe = document.createElement('iframe');
|
|
19
|
+
iframe.id = 'test-iframe-' + id;
|
|
20
|
+
iframe.className = 'test-iframe';
|
|
21
|
+
if (/\.html?$/i.test(fileName)) {
|
|
22
|
+
iframe.src = '/' + fileName + '?id=' + id + (this.options.failOnce ? '&flags=F' : '');
|
|
23
|
+
iframe.onerror = error => window.__tape6_error(id, error);
|
|
24
|
+
document.body.append(iframe);
|
|
25
|
+
} else {
|
|
26
|
+
document.body.append(iframe);
|
|
27
|
+
iframe.contentWindow.document.open();
|
|
28
|
+
iframe.contentWindow.document.write(`
|
|
29
|
+
<!doctype html>
|
|
30
|
+
<html>
|
|
31
|
+
<head>
|
|
32
|
+
<title>Test IFRAME</title>
|
|
33
|
+
<script type="module">
|
|
34
|
+
window.__tape6_id = ${JSON.stringify(id)};
|
|
35
|
+
window.__tape6_flags = "${this.options.failOnce ? 'F' : ''}";
|
|
36
|
+
const s = document.createElement('script');
|
|
37
|
+
s.setAttribute('type', 'module');
|
|
38
|
+
s.src = '/${fileName}';
|
|
39
|
+
s.onerror = error => window.parent.__tape6_error(window.__tape6_id, error);
|
|
40
|
+
document.documentElement.appendChild(s);
|
|
41
|
+
</script>
|
|
42
|
+
</head>
|
|
43
|
+
<body></body>
|
|
44
|
+
</html>
|
|
45
|
+
`);
|
|
46
|
+
iframe.contentWindow.document.close();
|
|
47
|
+
}
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
destroyTask(id) {
|
|
51
|
+
const iframe = document.getElementById('test-iframe-' + id);
|
|
52
|
+
iframe && iframe.parentElement.removeChild(iframe);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/webApp/donut.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const TWO_PI = 2 * Math.PI;
|
|
2
|
+
|
|
3
|
+
const tmpl = (template, dict) => template.replace(/\$\{([^\}]*)\}/g, (_, name) => dict[name]);
|
|
4
|
+
|
|
5
|
+
export const makeSegment = (args, options) => {
|
|
6
|
+
// args is {startAngle, angle, index, className}
|
|
7
|
+
// optional index points to a data point
|
|
8
|
+
// optional className is a CSS class
|
|
9
|
+
// default startAngle=0 (in radians)
|
|
10
|
+
// default angle=2*PI (in radians)
|
|
11
|
+
|
|
12
|
+
// options is {center, innerRadius, radius, gap, precision, document}
|
|
13
|
+
// default center is {x=0, y=0}
|
|
14
|
+
// default innerRadius=0
|
|
15
|
+
// default radius=100
|
|
16
|
+
// default gap=0 (gap between segments in pixels)
|
|
17
|
+
// default precision=6 (digits after decimal point)
|
|
18
|
+
// default document is document
|
|
19
|
+
|
|
20
|
+
const node = (options.document || document).createElementNS('http://www.w3.org/2000/svg', 'path'),
|
|
21
|
+
center = options.center || {x: 0, y: 0},
|
|
22
|
+
innerRadius = Math.max(options.innerRadius || 0, 0),
|
|
23
|
+
radius = Math.max(options.radius || 100, innerRadius),
|
|
24
|
+
gap = Math.max(options.gap || 0, 0),
|
|
25
|
+
precision = options.precision || 6,
|
|
26
|
+
angle = typeof args.angle != 'number' || args.angle >= TWO_PI ? TWO_PI : args.angle,
|
|
27
|
+
startAngle = args.startAngle || 0;
|
|
28
|
+
|
|
29
|
+
let path;
|
|
30
|
+
|
|
31
|
+
const innerGapAngle = gap / innerRadius / 2,
|
|
32
|
+
gapAngle = gap / radius / 2,
|
|
33
|
+
cx = center.x,
|
|
34
|
+
cy = center.y,
|
|
35
|
+
data = {
|
|
36
|
+
cx: cx.toFixed(precision),
|
|
37
|
+
cy: cy.toFixed(precision),
|
|
38
|
+
r: radius.toFixed(precision)
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (angle >= TWO_PI) {
|
|
42
|
+
data.tr = (2 * radius).toFixed(precision);
|
|
43
|
+
// generate a circle
|
|
44
|
+
if (innerRadius <= 0) {
|
|
45
|
+
// a circle
|
|
46
|
+
path = tmpl('M${cx} ${cy}m -${r} 0a${r} ${r} 0 1 0 ${tr} 0a${r} ${r} 0 1 0 -${tr} 0z', data);
|
|
47
|
+
} else {
|
|
48
|
+
data.r0 = innerRadius.toFixed(precision);
|
|
49
|
+
data.tr0 = (2 * innerRadius).toFixed(precision);
|
|
50
|
+
// a donut
|
|
51
|
+
path = tmpl(
|
|
52
|
+
'M${cx} ${cy}m -${r} 0a${r} ${r} 0 1 0 ${tr} 0a${r} ${r} 0 1 0 -${tr} 0zM${cx} ${cy}m -${r0} 0a${r0} ${r0} 0 1 1 ${tr0} 0a${r0} ${r0} 0 1 1 -${tr0} 0z',
|
|
53
|
+
data
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const endAngle = startAngle + angle;
|
|
58
|
+
let start = startAngle + gapAngle,
|
|
59
|
+
finish = endAngle - gapAngle;
|
|
60
|
+
if (finish < start) {
|
|
61
|
+
start = finish = startAngle + angle / 2;
|
|
62
|
+
}
|
|
63
|
+
data.lg = angle > Math.PI ? 1 : 0;
|
|
64
|
+
data.x1 = (radius * Math.cos(start) + cx).toFixed(precision);
|
|
65
|
+
data.y1 = (radius * Math.sin(start) + cy).toFixed(precision);
|
|
66
|
+
data.x2 = (radius * Math.cos(finish) + cx).toFixed(precision);
|
|
67
|
+
data.y2 = (radius * Math.sin(finish) + cy).toFixed(precision);
|
|
68
|
+
if (innerRadius <= 0) {
|
|
69
|
+
// a pie slice
|
|
70
|
+
path = tmpl('M${cx} ${cy}L${x1} ${y1}A${r} ${r} 0 ${lg} 1 ${x2} ${y2}L${cx} ${cy}z', data);
|
|
71
|
+
} else {
|
|
72
|
+
start = startAngle + innerGapAngle;
|
|
73
|
+
finish = endAngle - innerGapAngle;
|
|
74
|
+
if (finish < start) {
|
|
75
|
+
start = finish = startAngle + angle / 2;
|
|
76
|
+
}
|
|
77
|
+
data.r0 = innerRadius.toFixed(precision);
|
|
78
|
+
data.x3 = (innerRadius * Math.cos(finish) + cx).toFixed(precision);
|
|
79
|
+
data.y3 = (innerRadius * Math.sin(finish) + cy).toFixed(precision);
|
|
80
|
+
data.x4 = (innerRadius * Math.cos(start) + cx).toFixed(precision);
|
|
81
|
+
data.y4 = (innerRadius * Math.sin(start) + cy).toFixed(precision);
|
|
82
|
+
// a segment
|
|
83
|
+
path = tmpl('M${x1} ${y1}A${r} ${r} 0 ${lg} 1 ${x2} ${y2}L${x3} ${y3}A${r0} ${r0} 0 ${lg} 0 ${x4} ${y4}L${x1} ${y1}z', data);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
node.setAttribute('d', path);
|
|
87
|
+
if ('index' in args) {
|
|
88
|
+
node.setAttribute('data-index', args.index);
|
|
89
|
+
}
|
|
90
|
+
if (args.className) {
|
|
91
|
+
node.setAttribute('class', args.className);
|
|
92
|
+
}
|
|
93
|
+
return node;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const processPieRun = (data, options) => {
|
|
97
|
+
// data is [datum, datum...]
|
|
98
|
+
// datum is {value, className, skip, hide}
|
|
99
|
+
// value is a positive number
|
|
100
|
+
// className is an optional CSS class name
|
|
101
|
+
// skip is a flag (default: false) to skip this segment completely
|
|
102
|
+
// hide is a flag (default: false) to suppress rendering
|
|
103
|
+
|
|
104
|
+
// options is {center, innerRadius, radius, startAngle, minSizeInPx, skipIfLessInPx, emptyClass, precision}
|
|
105
|
+
// default center is {x=0, y=0}
|
|
106
|
+
// default innerRadius=0
|
|
107
|
+
// default radius=100
|
|
108
|
+
// default startAngle=0 (in radians)
|
|
109
|
+
// default gap=0 (gap between segments in pixels)
|
|
110
|
+
// default precision=6 (digits after decimal point)
|
|
111
|
+
// minSizeInPx is to make non-empty segments at least this big (default: 0).
|
|
112
|
+
// skipIfLessInPx is a threshold (default: 0), when to skip too small segments.
|
|
113
|
+
// emptyClass is a CSS class name for an empty run
|
|
114
|
+
|
|
115
|
+
const radius = Math.max(options.radius || 100, options.innerRadius || 0, 0),
|
|
116
|
+
gap = Math.max(options.gap || 0, 0),
|
|
117
|
+
minSizeInPx = Math.max(options.minSizeInPx || 0, 0),
|
|
118
|
+
skipIfLessInPx = Math.max(options.skipIfLessInPx || 0, gap),
|
|
119
|
+
runOptions = {
|
|
120
|
+
center: options.center,
|
|
121
|
+
innerRadius: options.innerRadius,
|
|
122
|
+
radius: radius,
|
|
123
|
+
gap: gap,
|
|
124
|
+
precision: options.precision,
|
|
125
|
+
document: options.document
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// sanitize data
|
|
129
|
+
data.forEach((datum, index) => {
|
|
130
|
+
if (!datum.skip && (isNaN(datum.value) || datum.value === null || datum.value <= 0)) {
|
|
131
|
+
datum.skip = true;
|
|
132
|
+
}
|
|
133
|
+
datum.index = index;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const total = data.reduce((acc, datum) => (datum.skip ? acc : acc + datum.value), 0);
|
|
137
|
+
|
|
138
|
+
let node;
|
|
139
|
+
if (total <= 0) {
|
|
140
|
+
// empty run
|
|
141
|
+
node = makeSegment(
|
|
142
|
+
{
|
|
143
|
+
index: -1, // to denote that it is not an actionable node
|
|
144
|
+
className: options.emptyClass
|
|
145
|
+
},
|
|
146
|
+
runOptions
|
|
147
|
+
);
|
|
148
|
+
return [node];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const nonEmptyDatumNumber = data.reduce((acc, datum) => (datum.skip ? acc : acc + 1), 0);
|
|
152
|
+
|
|
153
|
+
if (nonEmptyDatumNumber === 1) {
|
|
154
|
+
data.some(datum => {
|
|
155
|
+
if (datum.skip) return false;
|
|
156
|
+
node = makeSegment(
|
|
157
|
+
{
|
|
158
|
+
index: datum.index,
|
|
159
|
+
className: datum.className
|
|
160
|
+
},
|
|
161
|
+
runOptions
|
|
162
|
+
);
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
return [node];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// find too small segments
|
|
169
|
+
const sizes = data.map(datum => {
|
|
170
|
+
let angle = 0;
|
|
171
|
+
if (!datum.skip) {
|
|
172
|
+
angle = (datum.value / total) * TWO_PI;
|
|
173
|
+
}
|
|
174
|
+
return {angle: angle, index: datum.index};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let minAngle, newTotal, changeRatio;
|
|
178
|
+
if (minSizeInPx > 0) {
|
|
179
|
+
// adjust angles
|
|
180
|
+
minAngle = (minSizeInPx + gap) / radius;
|
|
181
|
+
sizes.forEach((size, index) => {
|
|
182
|
+
const datum = data[index];
|
|
183
|
+
if (!datum.skip && !datum.hide && size.angle < minAngle) {
|
|
184
|
+
size.angle = minAngle;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
newTotal = sizes.reduce((acc, size) => acc + size.angle, 0);
|
|
188
|
+
const excess = newTotal - total,
|
|
189
|
+
totalForLargeAngles = sizes.reduce((acc, size) => (size.angle <= minAngle ? acc : acc + size.angle), 0);
|
|
190
|
+
changeRatio = (totalForLargeAngles - excess) / totalForLargeAngles;
|
|
191
|
+
sizes.forEach(size => {
|
|
192
|
+
if (size.angle > minAngle) {
|
|
193
|
+
size.angle *= changeRatio;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
} else if (skipIfLessInPx > 0) {
|
|
197
|
+
// suppress angles
|
|
198
|
+
minAngle = skipIfLessInPx / radius;
|
|
199
|
+
sizes.forEach((size, index) => {
|
|
200
|
+
const datum = data[index];
|
|
201
|
+
if (!datum.skip && !datum.hide && size.angle < minAngle) {
|
|
202
|
+
size.angle = 0;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
newTotal = sizes.reduce((acc, size) => acc + size.angle, 0);
|
|
206
|
+
changeRatio = TWO_PI / newTotal;
|
|
207
|
+
sizes.forEach(size => {
|
|
208
|
+
if (size.angle > 0) {
|
|
209
|
+
size.angle *= changeRatio;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// generate shape objects
|
|
215
|
+
const shapes = [];
|
|
216
|
+
let startAngle = options.startAngle || 0;
|
|
217
|
+
data.forEach((datum, index) => {
|
|
218
|
+
if (!datum.skip) {
|
|
219
|
+
const angle = sizes[index].angle;
|
|
220
|
+
if (!datum.hide) {
|
|
221
|
+
shapes.push({
|
|
222
|
+
index: index,
|
|
223
|
+
startAngle: startAngle,
|
|
224
|
+
angle: angle,
|
|
225
|
+
className: datum.className
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
startAngle += angle;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return shapes.map(shape => makeSegment(shape, runOptions));
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const addShapes = parent => node => parent.appendChild(node);
|